Scan.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, ownCloud, Inc.
  4. *
  5. * @author Bart Visscher <bartv@thisnet.nl>
  6. * @author Blaok <i@blaok.me>
  7. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  8. * @author Daniel Kesselberg <mail@danielkesselberg.de>
  9. * @author J0WI <J0WI@users.noreply.github.com>
  10. * @author Joas Schilling <coding@schilljs.com>
  11. * @author Joel S <joel.devbox@protonmail.com>
  12. * @author Jörn Friedrich Dreyer <jfd@butonic.de>
  13. * @author martin.mattel@diemattels.at <martin.mattel@diemattels.at>
  14. * @author Robin Appelman <robin@icewind.nl>
  15. * @author Roeland Jago Douma <roeland@famdouma.nl>
  16. * @author Thomas Müller <thomas.mueller@tmit.eu>
  17. * @author Vincent Petry <vincent@nextcloud.com>
  18. *
  19. * @license AGPL-3.0
  20. *
  21. * This code is free software: you can redistribute it and/or modify
  22. * it under the terms of the GNU Affero General Public License, version 3,
  23. * as published by the Free Software Foundation.
  24. *
  25. * This program is distributed in the hope that it will be useful,
  26. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  27. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  28. * GNU Affero General Public License for more details.
  29. *
  30. * You should have received a copy of the GNU Affero General Public License, version 3,
  31. * along with this program. If not, see <http://www.gnu.org/licenses/>
  32. *
  33. */
  34. namespace OCA\Files\Command;
  35. use OC\Core\Command\Base;
  36. use OC\Core\Command\InterruptedException;
  37. use OC\DB\Connection;
  38. use OC\DB\ConnectionAdapter;
  39. use OCP\Files\File;
  40. use OC\ForbiddenException;
  41. use OC\Metadata\MetadataManager;
  42. use OCP\EventDispatcher\IEventDispatcher;
  43. use OCP\Files\IRootFolder;
  44. use OCP\Files\Mount\IMountPoint;
  45. use OCP\Files\NotFoundException;
  46. use OCP\Files\StorageNotAvailableException;
  47. use OCP\IUserManager;
  48. use Psr\Log\LoggerInterface;
  49. use Symfony\Component\Console\Helper\Table;
  50. use Symfony\Component\Console\Input\InputArgument;
  51. use Symfony\Component\Console\Input\InputInterface;
  52. use Symfony\Component\Console\Input\InputOption;
  53. use Symfony\Component\Console\Output\OutputInterface;
  54. class Scan extends Base {
  55. private IUserManager $userManager;
  56. protected float $execTime = 0;
  57. protected int $foldersCounter = 0;
  58. protected int $filesCounter = 0;
  59. protected int $errorsCounter = 0;
  60. private IRootFolder $root;
  61. private MetadataManager $metadataManager;
  62. public function __construct(
  63. IUserManager $userManager,
  64. IRootFolder $rootFolder,
  65. MetadataManager $metadataManager
  66. ) {
  67. $this->userManager = $userManager;
  68. parent::__construct();
  69. $this->root = $rootFolder;
  70. $this->metadataManager = $metadataManager;
  71. }
  72. protected function configure() {
  73. parent::configure();
  74. $this
  75. ->setName('files:scan')
  76. ->setDescription('rescan filesystem')
  77. ->addArgument(
  78. 'user_id',
  79. InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
  80. 'will rescan all files of the given user(s)'
  81. )
  82. ->addOption(
  83. 'path',
  84. 'p',
  85. InputArgument::OPTIONAL,
  86. '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'
  87. )
  88. ->addOption(
  89. 'generate-metadata',
  90. null,
  91. InputOption::VALUE_NONE,
  92. 'Generate metadata for all scanned files'
  93. )
  94. ->addOption(
  95. 'all',
  96. null,
  97. InputOption::VALUE_NONE,
  98. 'will rescan all files of all known users'
  99. )->addOption(
  100. 'unscanned',
  101. null,
  102. InputOption::VALUE_NONE,
  103. 'only scan files which are marked as not fully scanned'
  104. )->addOption(
  105. 'shallow',
  106. null,
  107. InputOption::VALUE_NONE,
  108. 'do not scan folders recursively'
  109. )->addOption(
  110. 'home-only',
  111. null,
  112. InputOption::VALUE_NONE,
  113. 'only scan the home storage, ignoring any mounted external storage or share'
  114. );
  115. }
  116. protected function scanFiles(string $user, string $path, bool $scanMetadata, OutputInterface $output, bool $backgroundScan = false, bool $recursive = true, bool $homeOnly = false): void {
  117. $connection = $this->reconnectToDatabase($output);
  118. $scanner = new \OC\Files\Utils\Scanner(
  119. $user,
  120. new ConnectionAdapter($connection),
  121. \OC::$server->get(IEventDispatcher::class),
  122. \OC::$server->get(LoggerInterface::class)
  123. );
  124. # check on each file/folder if there was a user interrupt (ctrl-c) and throw an exception
  125. $scanner->listen('\OC\Files\Utils\Scanner', 'scanFile', function (string $path) use ($output, $scanMetadata) {
  126. $output->writeln("\tFile\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
  127. ++$this->filesCounter;
  128. $this->abortIfInterrupted();
  129. if ($scanMetadata) {
  130. $node = $this->root->get($path);
  131. if ($node instanceof File) {
  132. $this->metadataManager->generateMetadata($node, false);
  133. }
  134. }
  135. });
  136. $scanner->listen('\OC\Files\Utils\Scanner', 'scanFolder', function ($path) use ($output) {
  137. $output->writeln("\tFolder\t<info>$path</info>", OutputInterface::VERBOSITY_VERBOSE);
  138. ++$this->foldersCounter;
  139. $this->abortIfInterrupted();
  140. });
  141. $scanner->listen('\OC\Files\Utils\Scanner', 'StorageNotAvailable', function (StorageNotAvailableException $e) use ($output) {
  142. $output->writeln('Error while scanning, storage not available (' . $e->getMessage() . ')', OutputInterface::VERBOSITY_VERBOSE);
  143. ++$this->errorsCounter;
  144. });
  145. $scanner->listen('\OC\Files\Utils\Scanner', 'normalizedNameMismatch', function ($fullPath) use ($output) {
  146. $output->writeln("\t<error>Entry \"" . $fullPath . '" will not be accessible due to incompatible encoding</error>');
  147. ++$this->errorsCounter;
  148. });
  149. try {
  150. if ($backgroundScan) {
  151. $scanner->backgroundScan($path);
  152. } else {
  153. $scanner->scan($path, $recursive, $homeOnly ? [$this, 'filterHomeMount'] : null);
  154. }
  155. } catch (ForbiddenException $e) {
  156. $output->writeln("<error>Home storage for user $user not writable or 'files' subdirectory missing</error>");
  157. $output->writeln(' ' . $e->getMessage());
  158. $output->writeln('Make sure you\'re running the scan command only as the user the web server runs as');
  159. ++$this->errorsCounter;
  160. } catch (InterruptedException $e) {
  161. # exit the function if ctrl-c has been pressed
  162. $output->writeln('Interrupted by user');
  163. } catch (NotFoundException $e) {
  164. $output->writeln('<error>Path not found: ' . $e->getMessage() . '</error>');
  165. ++$this->errorsCounter;
  166. } catch (\Exception $e) {
  167. $output->writeln('<error>Exception during scan: ' . $e->getMessage() . '</error>');
  168. $output->writeln('<error>' . $e->getTraceAsString() . '</error>');
  169. ++$this->errorsCounter;
  170. }
  171. }
  172. public function filterHomeMount(IMountPoint $mountPoint) {
  173. // any mountpoint inside '/$user/files/'
  174. return substr_count($mountPoint->getMountPoint(), '/') <= 3;
  175. }
  176. protected function execute(InputInterface $input, OutputInterface $output): int {
  177. $inputPath = $input->getOption('path');
  178. if ($inputPath) {
  179. $inputPath = '/' . trim($inputPath, '/');
  180. [, $user,] = explode('/', $inputPath, 3);
  181. $users = [$user];
  182. } elseif ($input->getOption('all')) {
  183. $users = $this->userManager->search('');
  184. } else {
  185. $users = $input->getArgument('user_id');
  186. }
  187. # check quantity of users to be process and show it on the command line
  188. $users_total = count($users);
  189. if ($users_total === 0) {
  190. $output->writeln('<error>Please specify the user id to scan, --all to scan for all users or --path=...</error>');
  191. return 1;
  192. }
  193. $this->initTools($output);
  194. $user_count = 0;
  195. foreach ($users as $user) {
  196. if (is_object($user)) {
  197. $user = $user->getUID();
  198. }
  199. $path = $inputPath ? $inputPath : '/' . $user;
  200. ++$user_count;
  201. if ($this->userManager->userExists($user)) {
  202. $output->writeln("Starting scan for user $user_count out of $users_total ($user)");
  203. $this->scanFiles($user, $path, $input->getOption('generate-metadata'), $output, $input->getOption('unscanned'), !$input->getOption('shallow'), $input->getOption('home-only'));
  204. $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
  205. } else {
  206. $output->writeln("<error>Unknown user $user_count $user</error>");
  207. $output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
  208. }
  209. try {
  210. $this->abortIfInterrupted();
  211. } catch (InterruptedException $e) {
  212. break;
  213. }
  214. }
  215. $this->presentStats($output);
  216. return 0;
  217. }
  218. /**
  219. * Initialises some useful tools for the Command
  220. */
  221. protected function initTools(OutputInterface $output) {
  222. // Start the timer
  223. $this->execTime = -microtime(true);
  224. // Convert PHP errors to exceptions
  225. set_error_handler(
  226. fn (int $severity, string $message, string $file, int $line): bool =>
  227. $this->exceptionErrorHandler($output, $severity, $message, $file, $line),
  228. E_ALL
  229. );
  230. }
  231. /**
  232. * Processes PHP errors in order to be able to show them in the output
  233. *
  234. * @see https://www.php.net/manual/en/function.set-error-handler.php
  235. *
  236. * @param int $severity the level of the error raised
  237. * @param string $message
  238. * @param string $file the filename that the error was raised in
  239. * @param int $line the line number the error was raised
  240. */
  241. public function exceptionErrorHandler(OutputInterface $output, int $severity, string $message, string $file, int $line): bool {
  242. if (($severity === E_DEPRECATED) || ($severity === E_USER_DEPRECATED)) {
  243. // Do not show deprecation warnings
  244. return false;
  245. }
  246. $e = new \ErrorException($message, 0, $severity, $file, $line);
  247. $output->writeln('<error>Error during scan: ' . $e->getMessage() . '</error>');
  248. $output->writeln('<error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);
  249. ++$this->errorsCounter;
  250. return true;
  251. }
  252. /**
  253. * @param OutputInterface $output
  254. */
  255. protected function presentStats(OutputInterface $output) {
  256. // Stop the timer
  257. $this->execTime += microtime(true);
  258. $headers = [
  259. 'Folders',
  260. 'Files',
  261. 'Errors',
  262. 'Elapsed time',
  263. ];
  264. $niceDate = $this->formatExecTime();
  265. $rows = [
  266. $this->foldersCounter,
  267. $this->filesCounter,
  268. $this->errorsCounter,
  269. $niceDate,
  270. ];
  271. $table = new Table($output);
  272. $table
  273. ->setHeaders($headers)
  274. ->setRows([$rows]);
  275. $table->render();
  276. }
  277. /**
  278. * Formats microtime into a human readable format
  279. *
  280. * @return string
  281. */
  282. protected function formatExecTime() {
  283. $secs = (int)round($this->execTime);
  284. # convert seconds into HH:MM:SS form
  285. return sprintf('%02d:%02d:%02d', (int)($secs / 3600), ((int)($secs / 60) % 60), $secs % 60);
  286. }
  287. protected function reconnectToDatabase(OutputInterface $output): Connection {
  288. /** @var Connection $connection */
  289. $connection = \OC::$server->get(Connection::class);
  290. try {
  291. $connection->close();
  292. } catch (\Exception $ex) {
  293. $output->writeln("<info>Error while disconnecting from database: {$ex->getMessage()}</info>");
  294. }
  295. while (!$connection->isConnected()) {
  296. try {
  297. $connection->connect();
  298. } catch (\Exception $ex) {
  299. $output->writeln("<info>Error while re-connecting to database: {$ex->getMessage()}</info>");
  300. sleep(60);
  301. }
  302. }
  303. return $connection;
  304. }
  305. }