Passed
Push — master ( a70f28...060286 )
by Blizzz
11:44
created

Notify::configure()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 31
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 29
c 1
b 0
f 1
nc 1
nop 0
dl 0
loc 31
rs 9.456
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016 Robin Appelman <[email protected]>
4
 *
5
 * @author Ari Selseng <[email protected]>
6
 * @author Christoph Wurst <[email protected]>
7
 * @author Joas Schilling <[email protected]>
8
 * @author Robin Appelman <[email protected]>
9
 * @author Roeland Jago Douma <[email protected]>
10
 *
11
 * @license GNU AGPL version 3 or any later version
12
 *
13
 * This program is free software: you can redistribute it and/or modify
14
 * it under the terms of the GNU Affero General Public License as
15
 * published by the Free Software Foundation, either version 3 of the
16
 * License, or (at your option) any later version.
17
 *
18
 * This program is distributed in the hope that it will be useful,
19
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21
 * GNU Affero General Public License for more details.
22
 *
23
 * You should have received a copy of the GNU Affero General Public License
24
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
25
 *
26
 */
27
28
namespace OCA\Files_External\Command;
29
30
use Doctrine\DBAL\Exception\DriverException;
31
use OC\Core\Command\Base;
32
use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException;
33
use OCA\Files_External\Lib\StorageConfig;
34
use OCA\Files_External\Service\GlobalStoragesService;
35
use OCP\DB\QueryBuilder\IQueryBuilder;
36
use OCP\Files\Notify\IChange;
37
use OCP\Files\Notify\INotifyHandler;
38
use OCP\Files\Notify\IRenameChange;
39
use OCP\Files\Storage\INotifyStorage;
40
use OCP\Files\Storage\IStorage;
41
use OCP\Files\StorageNotAvailableException;
42
use OCP\IDBConnection;
43
use OCP\ILogger;
44
use OCP\IUserManager;
45
use Symfony\Component\Console\Input\InputArgument;
46
use Symfony\Component\Console\Input\InputInterface;
47
use Symfony\Component\Console\Input\InputOption;
48
use Symfony\Component\Console\Output\OutputInterface;
49
50
class Notify extends Base {
51
	/** @var GlobalStoragesService */
52
	private $globalService;
53
	/** @var IDBConnection */
54
	private $connection;
55
	/** @var ILogger */
56
	private $logger;
57
	/** @var IUserManager */
58
	private $userManager;
59
60
	public function __construct(
61
		GlobalStoragesService $globalService,
62
		IDBConnection $connection,
63
		ILogger $logger,
64
		IUserManager $userManager
65
	) {
66
		parent::__construct();
67
		$this->globalService = $globalService;
68
		$this->connection = $connection;
69
		$this->logger = $logger;
70
		$this->userManager = $userManager;
71
	}
72
73
	protected function configure() {
74
		$this
75
			->setName('files_external:notify')
76
			->setDescription('Listen for active update notifications for a configured external mount')
77
			->addArgument(
78
				'mount_id',
79
				InputArgument::REQUIRED,
80
				'the mount id of the mount to listen to'
81
			)->addOption(
82
				'user',
83
				'u',
84
				InputOption::VALUE_REQUIRED,
85
				'The username for the remote mount (required only for some mount configuration that don\'t store credentials)'
86
			)->addOption(
87
				'password',
88
				'p',
89
				InputOption::VALUE_REQUIRED,
90
				'The password for the remote mount (required only for some mount configuration that don\'t store credentials)'
91
			)->addOption(
92
				'path',
93
				'',
94
				InputOption::VALUE_REQUIRED,
95
				'The directory in the storage to listen for updates in',
96
				'/'
97
			)->addOption(
98
				'no-self-check',
99
				'',
100
				InputOption::VALUE_NONE,
101
				'Disable self check on startup'
102
			);
103
		parent::configure();
104
	}
105
106
	private function getUserOption(InputInterface $input): ?string {
107
		if ($input->getOption('user')) {
108
			return (string)$input->getOption('user');
109
		} elseif (isset($_ENV['NOTIFY_USER'])) {
110
			return (string)$_ENV['NOTIFY_USER'];
111
		} elseif (isset($_SERVER['NOTIFY_USER'])) {
112
			return (string)$_SERVER['NOTIFY_USER'];
113
		} else {
114
			return null;
115
		}
116
	}
117
118
	private function getPasswordOption(InputInterface $input): ?string {
119
		if ($input->getOption('password')) {
120
			return (string)$input->getOption('password');
121
		} elseif (isset($_ENV['NOTIFY_PASSWORD'])) {
122
			return (string)$_ENV['NOTIFY_PASSWORD'];
123
		} elseif (isset($_SERVER['NOTIFY_PASSWORD'])) {
124
			return (string)$_SERVER['NOTIFY_PASSWORD'];
125
		} else {
126
			return null;
127
		}
128
	}
129
130
	protected function execute(InputInterface $input, OutputInterface $output): int {
131
		$mount = $this->globalService->getStorage($input->getArgument('mount_id'));
132
		if (is_null($mount)) {
133
			$output->writeln('<error>Mount not found</error>');
134
			return 1;
135
		}
136
		$noAuth = false;
137
138
		$userOption = $this->getUserOption($input);
139
		$passwordOption = $this->getPasswordOption($input);
140
141
		// if only the user is provided, we get the user object to pass along to the auth backend
142
		// this allows using saved user credentials
143
		$user = ($userOption && !$passwordOption) ? $this->userManager->get($userOption) : null;
144
145
		try {
146
			$authBackend = $mount->getAuthMechanism();
147
			$authBackend->manipulateStorageConfig($mount, $user);
148
		} catch (InsufficientDataForMeaningfulAnswerException $e) {
149
			$noAuth = true;
150
		} catch (StorageNotAvailableException $e) {
151
			$noAuth = true;
152
		}
153
154
		if ($userOption) {
155
			$mount->setBackendOption('user', $userOption);
156
		}
157
		if ($passwordOption) {
158
			$mount->setBackendOption('password', $passwordOption);
159
		}
160
161
		try {
162
			$backend = $mount->getBackend();
163
			$backend->manipulateStorageConfig($mount, $user);
164
		} catch (InsufficientDataForMeaningfulAnswerException $e) {
165
			$noAuth = true;
166
		} catch (StorageNotAvailableException $e) {
167
			$noAuth = true;
168
		}
169
170
		try {
171
			$storage = $this->createStorage($mount);
172
		} catch (\Exception $e) {
173
			$output->writeln('<error>Error while trying to create storage</error>');
174
			if ($noAuth) {
175
				$output->writeln('<error>Username and/or password required</error>');
176
			}
177
			return 1;
178
		}
179
		if (!$storage instanceof INotifyStorage) {
180
			$output->writeln('<error>Mount of type "' . $mount->getBackend()->getText() . '" does not support active update notifications</error>');
181
			return 1;
182
		}
183
184
		$verbose = $input->getOption('verbose');
185
186
		$path = trim($input->getOption('path'), '/');
187
		$notifyHandler = $storage->notify($path);
188
189
		if (!$input->getOption('no-self-check')) {
190
			$this->selfTest($storage, $notifyHandler, $verbose, $output);
191
		}
192
193
		$notifyHandler->listen(function (IChange $change) use ($mount, $verbose, $output) {
194
			if ($verbose) {
195
				$this->logUpdate($change, $output);
196
			}
197
			if ($change instanceof IRenameChange) {
198
				$this->markParentAsOutdated($mount->getId(), $change->getTargetPath(), $output);
199
			}
200
			$this->markParentAsOutdated($mount->getId(), $change->getPath(), $output);
201
		});
202
		return 0;
203
	}
204
205
	private function createStorage(StorageConfig $mount) {
206
		$class = $mount->getBackend()->getStorageClass();
207
		return new $class($mount->getBackendOptions());
208
	}
209
210
	private function markParentAsOutdated($mountId, $path, OutputInterface $output) {
211
		$parent = ltrim(dirname($path), '/');
212
		if ($parent === '.') {
213
			$parent = '';
214
		}
215
216
		try {
217
			$storageIds = $this->getStorageIds($mountId);
218
		} catch (DriverException $ex) {
219
			$this->logger->logException($ex, ['message' => 'Error while trying to find correct storage ids.', 'level' => ILogger::WARN]);
0 ignored issues
show
Deprecated Code introduced by
The constant OCP\ILogger::WARN has been deprecated: 20.0.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

219
			$this->logger->logException($ex, ['message' => 'Error while trying to find correct storage ids.', 'level' => /** @scrutinizer ignore-deprecated */ ILogger::WARN]);

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
220
			$this->connection = $this->reconnectToDatabase($this->connection, $output);
221
			$output->writeln('<info>Needed to reconnect to the database</info>');
222
			$storageIds = $this->getStorageIds($mountId);
223
		}
224
		if (count($storageIds) === 0) {
225
			throw new StorageNotAvailableException('No storages found by mount ID ' . $mountId);
226
		}
227
		$storageIds = array_map('intval', $storageIds);
228
229
		$result = $this->updateParent($storageIds, $parent);
230
		if ($result === 0) {
231
			//TODO: Find existing parent further up the tree in the database and register that folder instead.
232
			$this->logger->info('Failed updating parent for "' . $path . '" while trying to register change. It may not exist in the filecache.');
233
		}
234
	}
235
236
	private function logUpdate(IChange $change, OutputInterface $output) {
237
		switch ($change->getType()) {
238
			case INotifyStorage::NOTIFY_ADDED:
239
				$text = 'added';
240
				break;
241
			case INotifyStorage::NOTIFY_MODIFIED:
242
				$text = 'modified';
243
				break;
244
			case INotifyStorage::NOTIFY_REMOVED:
245
				$text = 'removed';
246
				break;
247
			case INotifyStorage::NOTIFY_RENAMED:
248
				$text = 'renamed';
249
				break;
250
			default:
251
				return;
252
		}
253
254
		$text .= ' ' . $change->getPath();
255
		if ($change instanceof IRenameChange) {
256
			$text .= ' to ' . $change->getTargetPath();
257
		}
258
259
		$output->writeln($text);
260
	}
261
262
	/**
263
	 * @param int $mountId
264
	 * @return array
265
	 */
266
	private function getStorageIds($mountId) {
267
		$qb = $this->connection->getQueryBuilder();
268
		return $qb
269
			->select('storage_id')
270
			->from('mounts')
271
			->where($qb->expr()->eq('mount_id', $qb->createNamedParameter($mountId, IQueryBuilder::PARAM_INT)))
272
			->execute()
273
			->fetchAll(\PDO::FETCH_COLUMN);
274
	}
275
276
	/**
277
	 * @param array $storageIds
278
	 * @param string $parent
279
	 * @return int
280
	 */
281
	private function updateParent($storageIds, $parent) {
282
		$pathHash = md5(trim(\OC_Util::normalizeUnicode($parent), '/'));
283
		$qb = $this->connection->getQueryBuilder();
284
		return $qb
0 ignored issues
show
Bug Best Practice introduced by
The expression return $qb->update('file...PARAM_STR)))->execute() also could return the type Doctrine\DBAL\Driver\Statement which is incompatible with the documented return type integer.
Loading history...
285
			->update('filecache')
286
			->set('size', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))
287
			->where($qb->expr()->in('storage', $qb->createNamedParameter($storageIds, IQueryBuilder::PARAM_INT_ARRAY, ':storage_ids')))
288
			->andWhere($qb->expr()->eq('path_hash', $qb->createNamedParameter($pathHash, IQueryBuilder::PARAM_STR)))
289
			->execute();
290
	}
291
292
	/**
293
	 * @return \OCP\IDBConnection
294
	 */
295
	private function reconnectToDatabase(IDBConnection $connection, OutputInterface $output) {
296
		try {
297
			$connection->close();
298
		} catch (\Exception $ex) {
299
			$this->logger->logException($ex, ['app' => 'files_external', 'message' => 'Error while disconnecting from DB', 'level' => ILogger::WARN]);
0 ignored issues
show
Deprecated Code introduced by
The constant OCP\ILogger::WARN has been deprecated: 20.0.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

299
			$this->logger->logException($ex, ['app' => 'files_external', 'message' => 'Error while disconnecting from DB', 'level' => /** @scrutinizer ignore-deprecated */ ILogger::WARN]);

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
300
			$output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
301
		}
302
		while (!$connection->isConnected()) {
0 ignored issues
show
Bug introduced by
The method isConnected() does not exist on OCP\IDBConnection. Since it exists in all sub-types, consider adding an abstract or default implementation to OCP\IDBConnection. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

302
		while (!$connection->/** @scrutinizer ignore-call */ isConnected()) {
Loading history...
303
			try {
304
				$connection->connect();
305
			} catch (\Exception $ex) {
306
				$this->logger->logException($ex, ['app' => 'files_external', 'message' => 'Error while re-connecting to database', 'level' => ILogger::WARN]);
0 ignored issues
show
Deprecated Code introduced by
The constant OCP\ILogger::WARN has been deprecated: 20.0.0 ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

306
				$this->logger->logException($ex, ['app' => 'files_external', 'message' => 'Error while re-connecting to database', 'level' => /** @scrutinizer ignore-deprecated */ ILogger::WARN]);

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
307
				$output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
308
				sleep(60);
309
			}
310
		}
311
		return $connection;
312
	}
313
314
315
	private function selfTest(IStorage $storage, INotifyHandler $notifyHandler, $verbose, OutputInterface $output) {
316
		usleep(100 * 1000); //give time for the notify to start
317
		$storage->file_put_contents('/.nc_test_file.txt', 'test content');
318
		$storage->mkdir('/.nc_test_folder');
319
		$storage->file_put_contents('/.nc_test_folder/subfile.txt', 'test content');
320
321
		usleep(100 * 1000); //time for all changes to be processed
322
		$changes = $notifyHandler->getChanges();
323
324
		$storage->unlink('/.nc_test_file.txt');
325
		$storage->unlink('/.nc_test_folder/subfile.txt');
326
		$storage->rmdir('/.nc_test_folder');
327
328
		usleep(100 * 1000); //time for all changes to be processed
329
		$notifyHandler->getChanges(); // flush
330
331
		$foundRootChange = false;
332
		$foundSubfolderChange = false;
333
334
		foreach ($changes as $change) {
335
			if ($change->getPath() === '/.nc_test_file.txt' || $change->getPath() === '.nc_test_file.txt') {
336
				$foundRootChange = true;
337
			} elseif ($change->getPath() === '/.nc_test_folder/subfile.txt' || $change->getPath() === '.nc_test_folder/subfile.txt') {
338
				$foundSubfolderChange = true;
339
			}
340
		}
341
342
		if ($foundRootChange && $foundSubfolderChange && $verbose) {
343
			$output->writeln('<info>Self-test successful</info>');
344
		} elseif ($foundRootChange && !$foundSubfolderChange) {
345
			$output->writeln('<error>Error while running self-test, change is subfolder not detected</error>');
346
		} elseif (!$foundRootChange) {
347
			$output->writeln('<error>Error while running self-test, no changes detected</error>');
348
		}
349
	}
350
}
351