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]); |
|
|
|
|
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 |
|
|
|
|
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]); |
|
|
|
|
320
|
|
|
$output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>"); |
321
|
|
|
} |
322
|
|
|
while (!$connection->isConnected()) { |
|
|
|
|
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]); |
|
|
|
|
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
|
|
|
|
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.