Scan.php 11 KB

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