‪TYPO3CMS  11.5
LostFilesCommand.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
20 use Symfony\Component\Console\Command\Command;
21 use Symfony\Component\Console\Input\InputInterface;
22 use Symfony\Component\Console\Input\InputOption;
23 use Symfony\Component\Console\Output\OutputInterface;
24 use Symfony\Component\Console\Style\SymfonyStyle;
32 
36 class ‪LostFilesCommand extends Command
37 {
41  private ‪$connectionPool;
42 
44  {
45  $this->connectionPool = ‪$connectionPool;
46  parent::__construct();
47  }
51  public function ‪configure()
52  {
53  $this
54  ->setHelp('
55 Assumptions:
56 - a perfect integrity of the reference index table (always update the reference index table before using this tool!)
57 - that all contents in the uploads folder are files attached to TCA records and exclusively managed by DataHandler through "group" type fields
58 - index.html, .htaccess files (ignored)
59 - Files found in deleted records are included (otherwise you would see a false list of lost files)
60 
61 The assumptions are not requirements by the TYPO3 API but reflect the de facto implementation of most TYPO3 installations and therefore are a practical approach to clean up the uploads/ or custom folder.
62 Therefore, if all "group" type fields in TCA and flexforms are positioned inside the uploads/ folder and if no files inside are managed manually it should be safe to clean out files with no relations found in the system.
63 Under such circumstances there should theoretically be no lost files in the uploads/ or custom folder since DataHandler should have managed relations automatically including adding and deleting files.
64 However, there is at least one reason known to why files might be found lost and that is when FlexForms are used. In such a case a change of/in the Data Structure XML (or the ability of the system to find the Data Structure definition!) used for the flexform could leave lost files behind. This is not unlikely to happen when records are deleted. More details can be found in a note to the function FlexFormTools->getDataStructureIdentifier()
65 Another scenario could of course be de-installation of extensions which managed files in the uploads/ or custom folders.
66 
67 If the option "--dry-run" is not set, the files are then deleted automatically.
68 Warning: First, make sure those files are not used somewhere TYPO3 does not know about! See the assumptions above.
69 
70 If you want to get more detailed information, use the --verbose option.')
71  ->addOption(
72  'exclude',
73  null,
74  InputOption::VALUE_REQUIRED,
75  'Comma-separated list of paths that should be excluded, e.g. "uploads/pics,uploads/media"'
76  )
77  ->addOption(
78  'dry-run',
79  null,
80  InputOption::VALUE_NONE,
81  'If this option is set, the files will not actually be deleted, but just the output which files would be deleted are shown'
82  )
83  ->addOption(
84  'update-refindex',
85  null,
86  InputOption::VALUE_NONE,
87  'Setting this option automatically updates the reference index and does not ask on command line. Alternatively, use -n to avoid the interactive mode'
88  )
89  ->addOption(
90  'custom-path',
91  null,
92  InputOption::VALUE_REQUIRED,
93  'Comma separated list of paths to process. Example: "fileadmin/[path1],fileadmin/[path2],...", if not passed, uploads/ will be used by default.'
94  );
95  }
96 
107  protected function ‪execute(InputInterface $input, OutputInterface ‪$output)
108  {
109  // Make sure the _cli_ user is loaded
111 
112  $io = new SymfonyStyle($input, ‪$output);
113  $io->title($this->getDescription());
114 
115  $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
116 
117  $this->‪updateReferenceIndex($input, $io);
118 
119  // Find the lost files
120  if ($input->hasOption('exclude') && !empty($input->getOption('exclude'))) {
121  $exclude = $input->getOption('exclude');
122  $exclude = is_string($exclude) ? $exclude : '';
123  $excludedPaths = ‪GeneralUtility::trimExplode(',', $exclude, true);
124  } else {
125  $excludedPaths = [];
126  }
127 
128  // Use custom-path
129  $customPaths = '';
130  if ($input->hasOption('custom-path') && !empty($input->getOption('custom-path'))) {
131  $customPaths = $input->getOption('custom-path');
132  $customPaths = is_string($customPaths) ? $customPaths : '';
133  }
134 
135  $lostFiles = $this->‪findLostFiles($excludedPaths, $customPaths);
136 
137  if (count($lostFiles)) {
138  if (!$io->isQuiet()) {
139  $io->note('Found ' . count($lostFiles) . ' lost files, ready to be deleted.');
140  if ($io->isVerbose()) {
141  $io->listing($lostFiles);
142  }
143  }
144 
145  // Delete them
146  $this->‪deleteLostFiles($lostFiles, $dryRun, $io);
147 
148  $io->success('Deleted ' . count($lostFiles) . ' lost files.');
149  } else {
150  $io->success('Nothing to do, no lost files found');
151  }
152  return 0;
153  }
154 
164  protected function ‪updateReferenceIndex(InputInterface $input, SymfonyStyle $io)
165  {
166  // Check for reference index to update
167  $io->note('Finding lost files managed by TYPO3 requires a clean reference index (sys_refindex)');
168  $updateReferenceIndex = false;
169  if ($input->hasOption('update-refindex') && $input->getOption('update-refindex')) {
170  $updateReferenceIndex = true;
171  } elseif ($input->isInteractive()) {
172  $updateReferenceIndex = $io->confirm('Should the reference index be updated right now?', false);
173  }
174 
175  // Update the reference index
176  if ($updateReferenceIndex) {
177  $progressListener = GeneralUtility::makeInstance(ReferenceIndexProgressListener::class);
178  $progressListener->initialize($io);
179  $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class);
180  $io->section('Reference Index is now being updated');
181  $referenceIndex->updateIndex(false, $progressListener);
182  } else {
183  $io->writeln('Reference index is assumed to be up to date, continuing.');
184  }
185  }
186 
194  protected function ‪findLostFiles($excludedPaths = [], $customPaths = ''): array
195  {
196  $lostFiles = [];
197 
198  // Get all files
199  $files = [];
200  if (!empty($customPaths)) {
201  $customPaths = ‪GeneralUtility::trimExplode(',', $customPaths, true);
202  foreach ($customPaths as $customPath) {
203  if (realpath(‪Environment::getPublicPath() . '/' . $customPath) === false
204  || !str_starts_with((string)realpath(‪Environment::getPublicPath() . '/' . $customPath), (string)realpath(‪Environment::getPublicPath()))) {
205  throw new \Exception('The path: "' . $customPath . '" is invalid', 1450086736);
206  }
207  $files = GeneralUtility::getAllFilesAndFoldersInPath($files, ‪Environment::getPublicPath() . '/' . $customPath);
208  }
209  } else {
210  $files = GeneralUtility::getAllFilesAndFoldersInPath($files, ‪Environment::getPublicPath() . '/uploads/');
211  }
212 
213  $files = GeneralUtility::removePrefixPathFromList($files, ‪Environment::getPublicPath() . '/');
214 
215  $queryBuilder = $this->connectionPool
216  ->getQueryBuilderForTable('sys_refindex');
217 
218  // Traverse files and for each, look up if its found in the reference index.
219  foreach ($files as $key => $value) {
220  // First, allow "index.html", ".htaccess" files since they are often used for good reasons
221  if (substr($value, -11) === '/index.html' || substr($value, -10) === '/.htaccess') {
222  continue;
223  }
224 
225  $fileIsInExcludedPath = false;
226  foreach ($excludedPaths as $exclPath) {
227  if (str_starts_with($value, $exclPath)) {
228  $fileIsInExcludedPath = true;
229  break;
230  }
231  }
232 
233  if ($fileIsInExcludedPath) {
234  continue;
235  }
236 
237  // Looking for a reference from a field which is NOT a soft reference (thus, only fields with a proper TCA/Flexform configuration)
238  $queryBuilder
239  ->select('hash')
240  ->from('sys_refindex')
241  ->where(
242  $queryBuilder->expr()->eq(
243  'ref_table',
244  $queryBuilder->createNamedParameter('_FILE', ‪Connection::PARAM_STR)
245  ),
246  $queryBuilder->expr()->eq(
247  'ref_string',
248  $queryBuilder->createNamedParameter($value, ‪Connection::PARAM_STR)
249  ),
250  $queryBuilder->expr()->eq(
251  'softref_key',
252  $queryBuilder->createNamedParameter('', ‪Connection::PARAM_STR)
253  )
254  )
255  ->orderBy('sorting', 'DESC')
256  // @todo Executing and not assigning and use the result looks weired, at least with the
257  // circumstance that the same QueryBuilder is reused as count query and executed
258  // directly afterwards - must be rechecked and either solved or proper commented
259  // why this mystery is needed here as this is not obvious and against general
260  // recommendation to not reuse the QueryBuilder.
261  ->executeQuery();
262 
263  $rowCount = $queryBuilder->count('hash')->executeQuery()->fetchOne();
264  // We conclude that the file is lost
265  if ($rowCount === 0) {
266  $lostFiles[] = $value;
267  }
268  }
269 
270  return $lostFiles;
271  }
272 
280  protected function ‪deleteLostFiles(array $lostFiles, bool $dryRun, SymfonyStyle $io)
281  {
282  foreach ($lostFiles as $lostFile) {
283  $absoluteFileName = GeneralUtility::getFileAbsFileName($lostFile);
284  if ($io->isVeryVerbose()) {
285  $io->writeln('Deleting file "' . $absoluteFileName . '"');
286  }
287  if (!$dryRun) {
288  if ($absoluteFileName && @is_file($absoluteFileName)) {
289  unlink($absoluteFileName);
290  if (!$io->isQuiet()) {
291  $io->writeln('Permanently deleted file record "' . $absoluteFileName . '".');
292  }
293  } else {
294  $io->error('File "' . $absoluteFileName . '" was not found!');
295  }
296  }
297  }
298  }
299 }
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\findLostFiles
‪array findLostFiles($excludedPaths=[], $customPaths='')
Definition: LostFilesCommand.php:193
‪TYPO3\CMS\Core\Utility\GeneralUtility\trimExplode
‪static list< string > trimExplode($delim, $string, $removeEmptyValues=false, $limit=0)
Definition: GeneralUtility.php:999
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\configure
‪configure()
Definition: LostFilesCommand.php:50
‪TYPO3\CMS\Core\Core\Environment\getPublicPath
‪static string getPublicPath()
Definition: Environment.php:206
‪TYPO3\CMS\Backend\Command\ProgressListener\ReferenceIndexProgressListener
Definition: ReferenceIndexProgressListener.php:30
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\__construct
‪__construct(ConnectionPool $connectionPool)
Definition: LostFilesCommand.php:42
‪TYPO3\CMS\Core\Database\ReferenceIndex
Definition: ReferenceIndex.php:42
‪TYPO3\CMS\Core\Core\Bootstrap\initializeBackendAuthentication
‪static initializeBackendAuthentication($proceedIfNoUserIsLoggedIn=false)
Definition: Bootstrap.php:588
‪TYPO3\CMS\Core\Database\Connection\PARAM_STR
‪const PARAM_STR
Definition: Connection.php:54
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\updateReferenceIndex
‪updateReferenceIndex(InputInterface $input, SymfonyStyle $io)
Definition: LostFilesCommand.php:163
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\execute
‪int execute(InputInterface $input, OutputInterface $output)
Definition: LostFilesCommand.php:106
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\deleteLostFiles
‪deleteLostFiles(array $lostFiles, bool $dryRun, SymfonyStyle $io)
Definition: LostFilesCommand.php:279
‪$output
‪$output
Definition: annotationChecker.php:121
‪TYPO3\CMS\Core\Database\Connection
Definition: Connection.php:38
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand
Definition: LostFilesCommand.php:37
‪TYPO3\CMS\Core\Core\Environment
Definition: Environment.php:43
‪TYPO3\CMS\Core\Core\Bootstrap
Definition: Bootstrap.php:70
‪TYPO3\CMS\Lowlevel\Command
Definition: CleanFlexFormsCommand.php:18
‪TYPO3\CMS\Lowlevel\Command\LostFilesCommand\$connectionPool
‪ConnectionPool $connectionPool
Definition: LostFilesCommand.php:40
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:46
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:50