1<?php
2/**
3 * @author Björn Schießle <bjoern@schiessle.org>
4 * @author Joas Schilling <coding@schilljs.com>
5 * @author Lukas Reschke <lukas@statuscode.ch>
6 * @author Thomas Müller <thomas.mueller@tmit.eu>
7 * @author Vincent Petry <pvince81@owncloud.com>
8 *
9 * @copyright Copyright (c) 2018, ownCloud GmbH
10 * @license AGPL-3.0
11 *
12 * This code is free software: you can redistribute it and/or modify
13 * it under the terms of the GNU Affero General Public License, version 3,
14 * as published by the Free Software Foundation.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU Affero General Public License for more details.
20 *
21 * You should have received a copy of the GNU Affero General Public License, version 3,
22 * along with this program.  If not, see <http://www.gnu.org/licenses/>
23 *
24 */
25
26namespace OCA\FederatedFileSharing;
27
28use OCA\FederatedFileSharing\Ocm\NotificationManager;
29use OCP\AppFramework\Http;
30use OCP\BackgroundJob\IJobList;
31use OCP\Http\Client\IClientService;
32use OCP\IConfig;
33use GuzzleHttp\Exception\ClientException;
34
35class Notifications {
36	public const RESPONSE_FORMAT = 'json'; // default response format for ocs calls
37
38	/** @var AddressHandler */
39	private $addressHandler;
40
41	/** @var IClientService */
42	private $httpClientService;
43
44	/** @var DiscoveryManager */
45	private $discoveryManager;
46
47	/** @var NotificationManager */
48	private $notificationManager;
49
50	/** @var IJobList  */
51	private $jobList;
52
53	/** @var IConfig */
54	private $config;
55
56	/**
57	 * @param AddressHandler $addressHandler
58	 * @param IClientService $httpClientService
59	 * @param DiscoveryManager $discoveryManager
60	 * @param IJobList $jobList
61	 * @param IConfig $config
62	 */
63	public function __construct(
64		AddressHandler $addressHandler,
65		IClientService $httpClientService,
66		DiscoveryManager $discoveryManager,
67		NotificationManager $notificationManager,
68		IJobList $jobList,
69		IConfig $config
70	) {
71		$this->addressHandler = $addressHandler;
72		$this->httpClientService = $httpClientService;
73		$this->discoveryManager = $discoveryManager;
74		$this->notificationManager = $notificationManager;
75		$this->jobList = $jobList;
76		$this->config = $config;
77	}
78
79	/**
80	 * send server-to-server share to remote server
81	 *
82	 * @param Address $shareWithAddress
83	 * @param Address $ownerAddress
84	 * @param Address $sharedByAddress
85	 * @param string $token
86	 * @param string $name
87	 * @param string $remote_id
88	 *
89	 * @return bool|array true if successful or status information
90	 *
91	 * @throws \OC\HintException
92	 * @throws \OC\ServerNotAvailableException
93	 * @throws \Exception
94	 */
95	public function sendRemoteShare(
96		Address $shareWithAddress,
97		Address $ownerAddress,
98		Address $sharedByAddress,
99		$token,
100		$name,
101		$remote_id
102	) {
103		$remoteShareSuccess = false;
104		if ($shareWithAddress->getUserId() && $shareWithAddress->getCloudId()) {
105			$remoteShareSuccess = $this->sendOcmRemoteShare(
106				$shareWithAddress,
107				$ownerAddress,
108				$sharedByAddress,
109				$token,
110				$name,
111				$remote_id
112			);
113			if (!$remoteShareSuccess) {
114				$remoteShareSuccess = $this->sendPreOcmRemoteShare(
115					$shareWithAddress,
116					$ownerAddress,
117					$sharedByAddress,
118					$token,
119					$name,
120					$remote_id
121				);
122			}
123		}
124		if ($remoteShareSuccess === true) {
125			\OC_Hook::emit(
126				'OCP\Share',
127				'federated_share_added',
128				['server' => $shareWithAddress->getHostName()]
129			);
130		}
131		return $remoteShareSuccess;
132	}
133
134	/**
135	 * ask owner to re-share the file with the given user
136	 *
137	 * @param string $token
138	 * @param int $id remote Id
139	 * @param int $shareId internal share Id
140	 * @param string $remote remote address of the owner
141	 * @param string $shareWith
142	 * @param int $permission
143	 * @return bool|array
144	 * @throws \Exception
145	 */
146	public function requestReShare($token, $id, $shareId, $remote, $shareWith, $permission) {
147		$data = [
148			'shareWith' => $shareWith,
149			'senderId' => $shareId
150		];
151		$ocmNotification = $this->notificationManager->convertToOcmFileNotification($id, $token, 'reshare', $data);
152		$ocmFields = $ocmNotification->toArray();
153
154		$url = \rtrim(
155			$this->addressHandler->removeProtocolFromUrl($remote),
156			'/'
157		);
158		$result = $this->tryHttpPostToShareEndpoint($url, '/notifications', $ocmFields, true);
159		if (isset($result['statusCode']) && $result['statusCode'] === Http::STATUS_CREATED) {
160			$response = \json_decode($result['result'], true);
161			if (\is_array($response) && isset($response['sharedSecret'], $response['providerId'])) {
162				return [
163					$response['sharedSecret'],
164					$response['providerId']
165				];
166			}
167			return true;
168		}
169
170		$fields = [
171			'shareWith' => $shareWith,
172			'token' => $token,
173			'permission' => $permission,
174			'remoteId' => $shareId
175		];
176
177		$url = $this->addressHandler->removeProtocolFromUrl($remote);
178		$result = $this->tryHttpPostToShareEndpoint(\rtrim($url, '/'), '/' . $id . '/reshare', $fields);
179		$status = \json_decode($result['result'], true);
180
181		$httpRequestSuccessful = $result['success'];
182		$validToken = isset($status['ocs']['data']['token']) && \is_string($status['ocs']['data']['token']);
183		$validRemoteId = isset($status['ocs']['data']['remoteId']);
184
185		if ($httpRequestSuccessful && $this->isOcsStatusOk($status) && $validToken && $validRemoteId) {
186			return [
187				$status['ocs']['data']['token'],
188				$status['ocs']['data']['remoteId']
189			];
190		}
191
192		return false;
193	}
194
195	/**
196	 * send server-to-server unshare to remote server
197	 *
198	 * @param string $remote url
199	 * @param int $id share id
200	 * @param string $token
201	 * @return bool
202	 */
203	public function sendRemoteUnShare($remote, $id, $token) {
204		return $this->sendUpdateToRemote($remote, $id, $token, 'unshare');
205	}
206
207	/**
208	 * send server-to-server unshare to remote server
209	 *
210	 * @param string $remote url
211	 * @param int $id share id
212	 * @param string $token
213	 * @return bool
214	 */
215	public function sendRevokeShare($remote, $id, $token) {
216		return $this->sendUpdateToRemote($remote, $id, $token, 'revoke');
217	}
218
219	/**
220	 * send notification to remote server if the permissions was changed
221	 *
222	 * @param string $remote
223	 * @param string $remoteId
224	 * @param string $token
225	 * @param int $permissions
226	 * @return bool
227	 */
228	public function sendPermissionChange($remote, $remoteId, $token, $permissions) {
229		return $this->sendUpdateToRemote($remote, $remoteId, $token, 'permissions', ['permissions' => $permissions]);
230	}
231
232	/**
233	 * forward accept reShare to remote server
234	 *
235	 * @param string $remote
236	 * @param string $remoteId
237	 * @param string $token
238	 */
239	public function sendAcceptShare($remote, $remoteId, $token) {
240		$this->sendUpdateToRemote($remote, $remoteId, $token, 'accept');
241	}
242
243	/**
244	 * forward decline reShare to remote server
245	 *
246	 * @param string $remote
247	 * @param string $remoteId
248	 * @param string $token
249	 */
250	public function sendDeclineShare($remote, $remoteId, $token) {
251		$this->sendUpdateToRemote($remote, $remoteId, $token, 'decline');
252	}
253
254	/**
255	 * inform remote server whether server-to-server share was accepted/declined
256	 *
257	 * @param string $remote
258	 * @param string $remoteId Share id on the remote host
259	 * @param string $token
260	 * @param string $action possible actions:
261	 * 	                     accept, decline, unshare, revoke, permissions
262	 * @param array $data
263	 * @param int $try
264	 *
265	 * @return boolean
266	 *
267	 * @throws \Exception
268	 */
269	public function sendUpdateToRemote($remote, $remoteId, $token, $action, $data = [], $try = 0) {
270		$ocmNotification = $this->notificationManager->convertToOcmFileNotification($remoteId, $token, $action, $data);
271		$ocmFields = $ocmNotification->toArray();
272		$url = \rtrim(
273			$this->addressHandler->removeProtocolFromUrl($remote),
274			'/'
275		);
276		$result = $this->tryHttpPostToShareEndpoint($url, '/notifications', $ocmFields, true);
277		if (isset($result['statusCode']) && $result['statusCode'] === Http::STATUS_CREATED) {
278			return true;
279		}
280
281		$fields = ['token' => $token];
282		foreach ($data as $key => $value) {
283			$fields[$key] = $value;
284		}
285
286		$url = \rtrim(
287			$this->addressHandler->removeProtocolFromUrl($remote),
288			'/'
289		);
290		$result = $this->tryHttpPostToShareEndpoint($url, '/' . $remoteId . '/' . $action, $fields);
291		$status = \json_decode($result['result'], true);
292
293		if ($result['success'] && $this->isOcsStatusOk($status)) {
294			return true;
295		} elseif ($try === 0) {
296			// only add new job on first try
297			$this->jobList->add(
298				'OCA\FederatedFileSharing\BackgroundJob\RetryJob',
299				[
300					'remote' => $remote,
301					'remoteId' => $remoteId,
302					'token' => $token,
303					'action' => $action,
304					'data' => \json_encode($data),
305					'try' => $try,
306					'lastRun' => $this->getTimestamp()
307				]
308			);
309		}
310
311		return false;
312	}
313
314	/**
315	 * return current timestamp
316	 *
317	 * @return int
318	 */
319	protected function getTimestamp() {
320		return \time();
321	}
322
323	/**
324	 * try http post first with https and then with http as a fallback
325	 *
326	 * @param string $remoteDomain
327	 * @param string $urlSuffix
328	 * @param array $fields post parameters
329	 * @param bool $useOcm send request to OCM endpoint instead of OCS
330	 *
331	 * @return array
332	 *
333	 * @throws \Exception
334	 */
335	protected function tryHttpPostToShareEndpoint($remoteDomain, $urlSuffix, array $fields, $useOcm = false) {
336		$client = $this->httpClientService->newClient();
337		$protocol = 'https://';
338		$result = [
339			'success' => false,
340			'result' => '',
341		];
342		$try = 0;
343
344		while ($result['success'] === false && $try < 2) {
345			try {
346				if ($useOcm) {
347					$endpoint = $this->discoveryManager->getOcmShareEndpoint($protocol . $remoteDomain);
348					$endpoint .= $urlSuffix;
349				} else {
350					$relativePath = $this->discoveryManager->getShareEndpoint($protocol . $remoteDomain);
351					$endpoint = $protocol . $remoteDomain . $relativePath . $urlSuffix . '?format=' . self::RESPONSE_FORMAT;
352				}
353
354				$options = [
355					'timeout' => 10,
356					'connect_timeout' => 10,
357				];
358				$sendAs = $useOcm === true ? 'json' : 'body';
359				$options[$sendAs] = $fields;
360				$response = $client->post($endpoint, $options);
361				$result['result'] = $response->getBody();
362				$result['statusCode'] = $response->getStatusCode();
363				$result['success'] = true;
364				break;
365			} catch (ClientException $e) {
366				// this exceptions happens for http error code 40x which the server sends
367				// if any data of the request is bad, ie. the username does not exist.
368				// In that case we want to retrieve an error message.
369				$response = $e->getResponse();
370				$result['result'] = $response->getBody();
371				$try++;
372			} catch (\Exception $e) {
373				// if flat re-sharing is not supported by the remote server
374				// we re-throw the exception and fall back to the old behaviour.
375				// (flat re-shares has been introduced in ownCloud 9.1)
376				if ($e->getCode() === Http::STATUS_INTERNAL_SERVER_ERROR) {
377					throw $e;
378				}
379
380				$allowHttpFallback = $this->config->getSystemValue('sharing.federation.allowHttpFallback', false) === true;
381				if (!$allowHttpFallback) {
382					break;
383				}
384				$try++;
385				$protocol = 'http://';
386			}
387		}
388
389		return $result;
390	}
391
392	/**
393	 * @param Address $shareWithAddress
394	 * @param Address $ownerAddress
395	 * @param Address $sharedByAddress
396	 * @param $token
397	 * @param $name
398	 * @param $remote_id
399	 * @return bool
400	 * @throws \Exception
401	 */
402	protected function sendOcmRemoteShare(Address $shareWithAddress, Address $ownerAddress, Address $sharedByAddress, $token, $name, $remote_id) {
403		$fields = [
404			'shareWith' => $shareWithAddress->getCloudId(),
405			'name' => $name,
406			'providerId' => (string) $remote_id,
407			'owner' => $ownerAddress->getCloudId(),
408			'ownerDisplayName' => $ownerAddress->getDisplayName(),
409			'sender' => $sharedByAddress->getCloudId(),
410			'senderDisplayName' => $sharedByAddress->getDisplayName(),
411			'shareType' => 'user',
412			'resourceType' => 'file',
413			'protocol' => [
414				'name' => 'webdav',
415				'options' => [
416					'sharedSecret' => $token
417				]
418			]
419		];
420
421		$url = $shareWithAddress->getHostName();
422		$result = $this->tryHttpPostToShareEndpoint($url, '/shares', $fields, true);
423
424		if (isset($result['statusCode']) && $result['statusCode'] === Http::STATUS_CREATED) {
425			return true;
426		}
427		return false;
428	}
429
430	/**
431	 * @param Address $shareWithAddress
432	 * @param Address $ownerAddress
433	 * @param Address $sharedByAddress
434	 * @param $token
435	 * @param $name
436	 * @param $remote_id
437	 * @return array|bool
438	 * @throws \Exception
439	 */
440	protected function sendPreOcmRemoteShare(Address $shareWithAddress, Address $ownerAddress, Address $sharedByAddress, $token, $name, $remote_id) {
441		$fields = [
442			'shareWith' => $shareWithAddress->getUserId(),
443			'token' => $token,
444			'name' => $name,
445			'remoteId' => $remote_id,
446			'owner' => $ownerAddress->getUserId(),
447			'ownerFederatedId' => $ownerAddress->getCloudId(),
448			'sharedBy' => $sharedByAddress->getUserId(),
449			'sharedByFederatedId' => $sharedByAddress->getUserId(),
450			'remote' => $this->addressHandler->generateRemoteURL(),
451		];
452		$url = $shareWithAddress->getHostName();
453		$result = $this->tryHttpPostToShareEndpoint($url, '', $fields);
454		$status = \json_decode($result['result'], true);
455		if ($result['success'] && $this->isOcsStatusOk($status)) {
456			return true;
457		}
458		return $status;
459	}
460
461	/**
462	 * Validate ocs response - 100 or 200 means success
463	 *
464	 * @param array $status
465	 *
466	 * @return bool
467	 */
468	private function isOcsStatusOk($status) {
469		return \in_array($status['ocs']['meta']['statuscode'], [100, 200]);
470	}
471}
472