Passed
Push — master ( 0a6416...f3738e )
by Roeland
17:50 queued 03:49
created

Notify::markParentAsOutdated()   B

Complexity

Conditions 6
Paths 16

Size

Total Lines 37
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 6
eloc 27
nc 16
nop 4
dl 0
loc 37
rs 8.8657
c 1
b 0
f 1
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
			)->addOption(
103
				'dry-run',
104
				'',
105
				InputOption::VALUE_NONE,
106
				'Don\'t make any changes, only log detected changes'
107
			);
108
		parent::configure();
109
	}
110
111
	private function getUserOption(InputInterface $input): ?string {
112
		if ($input->getOption('user')) {
113
			return (string)$input->getOption('user');
114
		} elseif (isset($_ENV['NOTIFY_USER'])) {
115
			return (string)$_ENV['NOTIFY_USER'];
116
		} elseif (isset($_SERVER['NOTIFY_USER'])) {
117
			return (string)$_SERVER['NOTIFY_USER'];
118
		} else {
119
			return null;
120
		}
121
	}
122
123
	private function getPasswordOption(InputInterface $input): ?string {
124
		if ($input->getOption('password')) {
125
			return (string)$input->getOption('password');
126
		} elseif (isset($_ENV['NOTIFY_PASSWORD'])) {
127
			return (string)$_ENV['NOTIFY_PASSWORD'];
128
		} elseif (isset($_SERVER['NOTIFY_PASSWORD'])) {
129
			return (string)$_SERVER['NOTIFY_PASSWORD'];
130
		} else {
131
			return null;
132
		}
133
	}
134
135
	protected function execute(InputInterface $input, OutputInterface $output): int {
136
		$mount = $this->globalService->getStorage($input->getArgument('mount_id'));
137
		if (is_null($mount)) {
138
			$output->writeln('<error>Mount not found</error>');
139
			return 1;
140
		}
141
		$noAuth = false;
142
143
		$userOption = $this->getUserOption($input);
144
		$passwordOption = $this->getPasswordOption($input);
145
146
		// if only the user is provided, we get the user object to pass along to the auth backend
147
		// this allows using saved user credentials
148
		$user = ($userOption && !$passwordOption) ? $this->userManager->get($userOption) : null;
149
150
		try {
151
			$authBackend = $mount->getAuthMechanism();
152
			$authBackend->manipulateStorageConfig($mount, $user);
153
		} catch (InsufficientDataForMeaningfulAnswerException $e) {
154
			$noAuth = true;
155
		} catch (StorageNotAvailableException $e) {
156
			$noAuth = true;
157
		}
158
159
		if ($userOption) {
160
			$mount->setBackendOption('user', $userOption);
161
		}
162
		if ($passwordOption) {
163
			$mount->setBackendOption('password', $passwordOption);
164
		}
165
166
		try {
167
			$backend = $mount->getBackend();
168
			$backend->manipulateStorageConfig($mount, $user);
169
		} catch (InsufficientDataForMeaningfulAnswerException $e) {
170
			$noAuth = true;
171
		} catch (StorageNotAvailableException $e) {
172
			$noAuth = true;
173
		}
174
175
		try {
176
			$storage = $this->createStorage($mount);
177
		} catch (\Exception $e) {
178
			$output->writeln('<error>Error while trying to create storage</error>');
179
			if ($noAuth) {
180
				$output->writeln('<error>Username and/or password required</error>');
181
			}
182
			return 1;
183
		}
184
		if (!$storage instanceof INotifyStorage) {
185
			$output->writeln('<error>Mount of type "' . $mount->getBackend()->getText() . '" does not support active update notifications</error>');
186
			return 1;
187
		}
188
189
		$dryRun = $input->getOption('dry-run');
190
		if ($dryRun && $output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
191
			$output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE);
192
		}
193
194
		$path = trim($input->getOption('path'), '/');
195
		$notifyHandler = $storage->notify($path);
196
197
		if (!$input->getOption('no-self-check')) {
198
			$this->selfTest($storage, $notifyHandler, $output);
199
		}
200
201
		$notifyHandler->listen(function (IChange $change) use ($mount, $output, $dryRun) {
202
			$this->logUpdate($change, $output);
203
			if ($change instanceof IRenameChange) {
204
				$this->markParentAsOutdated($mount->getId(), $change->getTargetPath(), $output, $dryRun);
205
			}
206
			$this->markParentAsOutdated($mount->getId(), $change->getPath(), $output, $dryRun);
207
		});
208
		return 0;
209
	}
210
211
	private function createStorage(StorageConfig $mount) {
212
		$class = $mount->getBackend()->getStorageClass();
213
		return new $class($mount->getBackendOptions());
214
	}
215
216
	private function markParentAsOutdated($mountId, $path, OutputInterface $output, bool $dryRun) {
217
		$parent = ltrim(dirname($path), '/');
218
		if ($parent === '.') {
219
			$parent = '';
220
		}
221
222
		try {
223
			$storages = $this->getStorageIds($mountId, $parent);
224
		} catch (DriverException $ex) {
225
			$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

225
			$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...
226
			$this->connection = $this->reconnectToDatabase($this->connection, $output);
227
			$output->writeln('<info>Needed to reconnect to the database</info>');
228
			$storages = $this->getStorageIds($mountId, $path);
229
		}
230
		if (count($storages) === 0) {
231
			$output->writeln("  no users found with access to '$parent', skipping", OutputInterface::VERBOSITY_VERBOSE);
232
			return;
233
		}
234
235
		$users = array_map(function (array $storage) {
236
			return $storage['user_id'];
237
		}, $storages);
238
239
		$output->writeln("  marking '$parent' as outdated for " . implode(', ', $users), OutputInterface::VERBOSITY_VERBOSE);
240
241
		$storageIds = array_map(function (array $storage) {
242
			return intval($storage['storage_id']);
243
		}, $storages);
244
		$storageIds = array_values(array_unique($storageIds));
245
246
		if ($dryRun) {
247
			$output->writeln("  dry-run: skipping database write");
248
		} else {
249
			$result = $this->updateParent($storageIds, $parent);
250
			if ($result === 0) {
251
				//TODO: Find existing parent further up the tree in the database and register that folder instead.
252
				$this->logger->info('Failed updating parent for "' . $path . '" while trying to register change. It may not exist in the filecache.');
253
			}
254
		}
255
	}
256
257
	private function logUpdate(IChange $change, OutputInterface $output) {
258
		switch ($change->getType()) {
259
			case INotifyStorage::NOTIFY_ADDED:
260
				$text = 'added';
261
				break;
262
			case INotifyStorage::NOTIFY_MODIFIED:
263
				$text = 'modified';
264
				break;
265
			case INotifyStorage::NOTIFY_REMOVED:
266
				$text = 'removed';
267
				break;
268
			case INotifyStorage::NOTIFY_RENAMED:
269
				$text = 'renamed';
270
				break;
271
			default:
272
				return;
273
		}
274
275
		$text .= ' ' . $change->getPath();
276
		if ($change instanceof IRenameChange) {
277
			$text .= ' to ' . $change->getTargetPath();
278
		}
279
280
		$output->writeln($text, OutputInterface::VERBOSITY_VERBOSE);
281
	}
282
283
	private function getStorageIds(int $mountId, string $path): array {
284
		$pathHash = md5(trim((string)\OC_Util::normalizeUnicode($path), '/'));
285
		$qb = $this->connection->getQueryBuilder();
286
		return $qb
287
			->select('storage_id', 'user_id')
288
			->from('mounts', 'm')
289
			->innerJoin('m', 'filecache', 'f', $qb->expr()->eq('m.storage_id', 'f.storage'))
290
			->where($qb->expr()->eq('mount_id', $qb->createNamedParameter($mountId, IQueryBuilder::PARAM_INT)))
291
			->andWhere($qb->expr()->eq('path_hash', $qb->createNamedParameter($pathHash, IQueryBuilder::PARAM_STR)))
292
			->execute()
293
			->fetchAll();
294
	}
295
296
	/**
297
	 * @param array $storageIds
298
	 * @param string $parent
299
	 * @return int
300
	 */
301
	private function updateParent($storageIds, $parent) {
302
		$pathHash = md5(trim(\OC_Util::normalizeUnicode($parent), '/'));
303
		$qb = $this->connection->getQueryBuilder();
304
		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 OCP\DB\IResult which is incompatible with the documented return type integer.
Loading history...
305
			->update('filecache')
306
			->set('size', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT))
307
			->where($qb->expr()->in('storage', $qb->createNamedParameter($storageIds, IQueryBuilder::PARAM_INT_ARRAY, ':storage_ids')))
308
			->andWhere($qb->expr()->eq('path_hash', $qb->createNamedParameter($pathHash, IQueryBuilder::PARAM_STR)))
309
			->execute();
310
	}
311
312
	/**
313
	 * @return \OCP\IDBConnection
314
	 */
315
	private function reconnectToDatabase(IDBConnection $connection, OutputInterface $output) {
316
		try {
317
			$connection->close();
318
		} catch (\Exception $ex) {
319
			$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

319
			$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...
320
			$output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
321
		}
322
		while (!$connection->isConnected()) {
0 ignored issues
show
Bug introduced by
The method isConnected() does not exist on 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

322
		while (!$connection->/** @scrutinizer ignore-call */ isConnected()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
323
			try {
324
				$connection->connect();
325
			} catch (\Exception $ex) {
326
				$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

326
				$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...
327
				$output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
328
				sleep(60);
329
			}
330
		}
331
		return $connection;
332
	}
333
334
335
	private function selfTest(IStorage $storage, INotifyHandler $notifyHandler, OutputInterface $output) {
336
		usleep(100 * 1000); //give time for the notify to start
337
		$storage->file_put_contents('/.nc_test_file.txt', 'test content');
338
		$storage->mkdir('/.nc_test_folder');
339
		$storage->file_put_contents('/.nc_test_folder/subfile.txt', 'test content');
340
341
		usleep(100 * 1000); //time for all changes to be processed
342
		$changes = $notifyHandler->getChanges();
343
344
		$storage->unlink('/.nc_test_file.txt');
345
		$storage->unlink('/.nc_test_folder/subfile.txt');
346
		$storage->rmdir('/.nc_test_folder');
347
348
		usleep(100 * 1000); //time for all changes to be processed
349
		$notifyHandler->getChanges(); // flush
350
351
		$foundRootChange = false;
352
		$foundSubfolderChange = false;
353
354
		foreach ($changes as $change) {
355
			if ($change->getPath() === '/.nc_test_file.txt' || $change->getPath() === '.nc_test_file.txt') {
356
				$foundRootChange = true;
357
			} elseif ($change->getPath() === '/.nc_test_folder/subfile.txt' || $change->getPath() === '.nc_test_folder/subfile.txt') {
358
				$foundSubfolderChange = true;
359
			}
360
		}
361
362
		if ($foundRootChange && $foundSubfolderChange) {
363
			$output->writeln('<info>Self-test successful</info>', OutputInterface::VERBOSITY_VERBOSE);
364
		} elseif ($foundRootChange) {
365
			$output->writeln('<error>Error while running self-test, change is subfolder not detected</error>');
366
		} else {
367
			$output->writeln('<error>Error while running self-test, no changes detected</error>');
368
		}
369
	}
370
}
371