Scan.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\Files\Command;
  8. use OC\Core\Command\Base;
  9. use OC\Core\Command\InterruptedException;
  10. use OC\DB\Connection;
  11. use OC\DB\ConnectionAdapter;
  12. use OC\FilesMetadata\FilesMetadataManager;
  13. use OC\ForbiddenException;
  14. use OCP\EventDispatcher\IEventDispatcher;
  15. use OCP\Files\Events\FileCacheUpdated;
  16. use OCP\Files\Events\NodeAddedToCache;
  17. use OCP\Files\Events\NodeRemovedFromCache;
  18. use OCP\Files\IRootFolder;
  19. use OCP\Files\Mount\IMountPoint;
  20. use OCP\Files\NotFoundException;
  21. use OCP\Files\StorageNotAvailableException;
  22. use OCP\FilesMetadata\IFilesMetadataManager;
  23. use OCP\IUserManager;
  24. use Psr\Log\LoggerInterface;
  25. use Symfony\Component\Console\Helper\Table;
  26. use Symfony\Component\Console\Input\InputArgument;
  27. use Symfony\Component\Console\Input\InputInterface;
  28. use Symfony\Component\Console\Input\InputOption;
  29. use Symfony\Component\Console\Output\OutputInterface;
  30. class Scan extends Base {
  31. protected float $execTime = 0;
  32. protected int $foldersCounter = 0;
  33. protected int $filesCounter = 0;
  34. protected int $errorsCounter = 0;
  35. protected int $newCounter = 0;
  36. protected int $updatedCounter = 0;
  37. protected int $removedCounter = 0;
  38. public function __construct(
  39. private IUserManager $userManager,
  40. private IRootFolder $rootFolder,
  41. private FilesMetadataManager $filesMetadataManager,
  42. private IEventDispatcher $eventDispatcher,
  43. private LoggerInterface $logger,
  44. ) {
  45. parent::__construct();
  46. }
  47. protected function configure(): void {
  48. parent::configure();
  49. $this
  50. ->setName('files:scan')
  51. ->setDescription('rescan filesystem')
  52. ->addArgument(
  53. 'user_id',
  54. InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
  55. 'will rescan all files of the given user(s)'
  56. )
  57. ->addOption(
  58. 'path',
  59. 'p',
  60. InputOption::VALUE_REQUIRED,
  61. 'limit rescan to this path, eg. --path="/alice/files/Music", the user_id is determined by the path and the user_id parameter and --all are ignored'
  62. )
  63. ->addOption(
  64. 'generate-metadata',
  65. null,
  66. InputOption::VALUE_OPTIONAL,
  67. 'Generate metadata for all scanned files; if specified only generate for named value',
  68. ''
  69. )
  70. ->addOption(
  71. 'all',
  72. null,
  73. InputOption::VALUE_NONE,
  74. 'will rescan all files of all known users'
  75. )->addOption(
  76. 'unscanned',
  77. null,
  78. InputOption::VALUE_NONE,
  79. 'only scan files which are marked as not fully scanned'
  80. )->addOption(
  81. 'shallow',
  82. null,
  83. InputOption::VALUE_NONE,
  84. 'do not scan folders recursively'
  85. )->addOption(
  86. 'home-only',
  87. null,
  88. InputOption::VALUE_NONE,
  89. 'only scan the home storage, ignoring any mounted external storage or share'
  90. );
  91. }
  92. protected function scanFiles(string $user, string $path, ?string $scanMetadata, OutputInterface $output, bool $backgroundScan = false, bool $recursive = true, bool $homeOnly = false): void {
  93. $connection = $this->reconnectToDatabase($output);
  94. $scanner = new \OC\Files\Utils\Scanner(
  95. $user,
  96. new ConnectionAdapter($connection),
  97. \OC::$server->get(IEventDispatcher::class),
  98. \OC::$server->get(LoggerInterface::class)
  99. );
  100. # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
  101. $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata) {
  102. $output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
  103. ++$this->filesCounter;
  104. $this->abortIfInterrupted();
  105. if ($scanMetadata !== null) {
  106. $node = $this->rootFolder->get($path);
  107. $this->filesMetadataManager->refreshMetadata(
  108. $node,
  109. ($scanMetadata !== '') ? IFilesMetadataManager::PROCESS_NAMED : IFilesMetadataManager::PROCESS_LIVE | IFilesMetadataManager::PROCESS_BACKGROUND,
  110. $scanMetadata
  111. );
  112. }
  113. });
  114. $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) {
  115. $output->writeln("\tFolder\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
  116. ++$this->foldersCounter;
  117. $this->abortIfInterrupted();
  118. });
  119. $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) {
  120. $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
  121. ++$this->errorsCounter;
  122. });
  123. $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) {
  124. $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
  125. ++$this->errorsCounter;
  126. });
  127. $this->eventDispatcher->addListener(NodeAddedToCache::class, function () {
  128. ++$this->newCounter;
  129. });
  130. $this->eventDispatcher->addListener(FileCacheUpdated::class, function () {
  131. ++$this->updatedCounter;
  132. });
  133. $this->eventDispatcher->addListener(NodeRemovedFromCache::class, function () {
  134. ++$this->removedCounter;
  135. });
  136. try {
  137. if ($backgroundScan) {
  138. $scanner->backgroundScan($path);
  139. } else {
  140. $scanner->scan($path, $recursive, $homeOnly ? [$this, 'filterHomeMount'] : null);
  141. }
  142. } catch (ForbiddenException $e) {
  143. $output->writeln("<error>Home storage for user $user not writable or 'files' subdirectory missing</error>");
  144. $output->writeln(' ' . $e->getMessage());
  145. $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as');
  146. ++$this->errorsCounter;
  147. } catch (InterruptedException $e) {
  148. # exit the function if ctrl-c has been pressed
  149. $output->writeln('Interrupted by user');
  150. } catch (NotFoundException $e) {
  151. $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
  152. ++$this->errorsCounter;
  153. } catch (\Exception $e) {
  154. $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
  155. $output->writeln('<error>' . $e->getTraceAsString() . '</error>');
  156. ++$this->errorsCounter;
  157. }
  158. }
  159. public function filterHomeMount(IMountPoint $mountPoint): bool {
  160. // any mountpoint inside '/$user/files/'
  161. return substr_count($mountPoint->getMountPoint(), '/') <= 3;
  162. }
  163. protected function execute(InputInterface $input, OutputInterface $output): int {
  164. $inputPath = $input->getOption('path');
  165. if ($inputPath) {
  166. $inputPath = '/' . trim($inputPath, '/');
  167. [, $user,] = explode('/', $inputPath, 3);
  168. $users = [$user];
  169. } elseif ($input->getOption('all')) {
  170. $users = $this->userManager->search('');
  171. } else {
  172. $users = $input->getArgument('user_id');
  173. }
  174. # check quantity of users to be process and show it on the command line
  175. $users_total = count($users);
  176. if ($users_total === 0) {
  177. $output->writeln('<error>Please specify the user id to scan, --all to scan for all users or --path=...</error>');
  178. return self::FAILURE;
  179. }
  180. $this->initTools($output);
  181. // getOption() logic on VALUE_OPTIONAL
  182. $metadata = null; // null if --generate-metadata is not set, empty if option have no value, value if set
  183. if ($input->getOption('generate-metadata') !== '') {
  184. $metadata = $input->getOption('generate-metadata') ?? '';
  185. }
  186. $user_count = 0;
  187. foreach ($users as $user) {
  188. if (is_object($user)) {
  189. $user = $user->getUID();
  190. }
  191. $path = $inputPath ?: '/' . $user;
  192. ++$user_count;
  193. if ($this->userManager->userExists($user)) {
  194. $output->writeln("Starting scan for user $user_count out of $users_total ($user)");
  195. $this->scanFiles($user, $path, $metadata, $output, $input->getOption('unscanned'), !$input->getOption('shallow'), $input->getOption('home-only'));
  196. $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
  197. } else {
  198. $output->writeln("<error>Unknown user $user_count $user</error>");
  199. $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
  200. }
  201. try {
  202. $this->abortIfInterrupted();
  203. } catch (InterruptedException $e) {
  204. break;
  205. }
  206. }
  207. $this->presentStats($output);
  208. return self::SUCCESS;
  209. }
  210. /**
  211. * Initialises some useful tools for the Command
  212. */
  213. protected function initTools(OutputInterface $output): void {
  214. // Start the timer
  215. $this->execTime = -microtime(true);
  216. // Convert PHP errors to exceptions
  217. set_error_handler(
  218. fn (int $severity, string $message, string $file, int $line): bool =>
  219. $this->exceptionErrorHandler($output, $severity, $message, $file, $line),
  220. E_ALL
  221. );
  222. }
  223. /**
  224. * Processes PHP errors in order to be able to show them in the output
  225. *
  226. * @see https://www.php.net/manual/en/function.set-error-handler.php
  227. *
  228. * @param int $severity the level of the error raised
  229. * @param string $message
  230. * @param string $file the filename that the error was raised in
  231. * @param int $line the line number the error was raised
  232. */
  233. public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool {
  234. if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) {
  235. // Do not show deprecation warnings
  236. return false;
  237. }
  238. $e = new \ErrorException($message, 0, $severity, $file, $line);
  239. $output->writeln('<error>Error during scan: ' . $e->getMessage() . '</error>');
  240. $output->writeln('<error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);
  241. ++$this->errorsCounter;
  242. return true;
  243. }
  244. protected function presentStats(OutputInterface $output): void {
  245. // Stop the timer
  246. $this->execTime += microtime(true);
  247. $this->logger->info("Completed scan of {$this->filesCounter} files in {$this->foldersCounter} folder. Found {$this->newCounter} new, {$this->updatedCounter} updated and {$this->removedCounter} removed items");
  248. $headers = [
  249. 'Folders',
  250. 'Files',
  251. 'New',
  252. 'Updated',
  253. 'Removed',
  254. 'Errors',
  255. 'Elapsed time',
  256. ];
  257. $niceDate = $this->formatExecTime();
  258. $rows = [
  259. $this->foldersCounter,
  260. $this->filesCounter,
  261. $this->newCounter,
  262. $this->updatedCounter,
  263. $this->removedCounter,
  264. $this->errorsCounter,
  265. $niceDate,
  266. ];
  267. $table = new Table($output);
  268. $table
  269. ->setHeaders($headers)
  270. ->setRows([$rows]);
  271. $table->render();
  272. }
  273. /**
  274. * Formats microtime into a human-readable format
  275. */
  276. protected function formatExecTime(): string {
  277. $secs = (int)round($this->execTime);
  278. # convert seconds into HH:MM:SS form
  279. return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60);
  280. }
  281. protected function reconnectToDatabase(OutputInterface $output): Connection {
  282. /** @var Connection $connection */
  283. $connection = \OC::$server->get(Connection::class);
  284. try {
  285. $connection->close();
  286. } catch (\Exception $ex) {
  287. $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
  288. }
  289. while (!$connection->isConnected()) {
  290. try {
  291. $connection->connect();
  292. } catch (\Exception $ex) {
  293. $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
  294. sleep(60);
  295. }
  296. }
  297. return $connection;
  298. }
  299. }