Your IP : 216.73.216.220


Current Path : /var/www/surf/TYPO3/vendor/typo3/cms-lowlevel/Classes/Command/
Upload File :
Current File : /var/www/surf/TYPO3/vendor/typo3/cms-lowlevel/Classes/Command/DeletedRecordsCommand.php

<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Lowlevel\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;

/**
 * Force-deletes all records in the database which have a deleted=1 flag
 */
class DeletedRecordsCommand extends Command
{
    public function __construct(private readonly ConnectionPool $connectionPool)
    {
        parent::__construct();
    }

    /**
     * Configure the command by defining the name, options and arguments
     */
    public function configure()
    {
        $this
            ->setHelp('Traverse page tree and find and flush deleted records. If you want to get more detailed information, use the --verbose option.')
            ->addOption(
                'pid',
                'p',
                InputOption::VALUE_REQUIRED,
                'Setting start page in page tree. Default is the page tree root, 0 (zero)'
            )
            ->addOption(
                'depth',
                'd',
                InputOption::VALUE_REQUIRED,
                'Setting traversal depth. 0 (zero) will only analyze start page (see --pid), 1 will traverse one level of subpages etc.'
            )
            ->addOption(
                'dry-run',
                null,
                InputOption::VALUE_NONE,
                'If this option is set, the records will not actually be deleted, but just the output which records would be deleted are shown'
            )
            ->addOption(
                'min-age',
                'm',
                InputOption::VALUE_REQUIRED,
                'Minimum age in days records need to be marked for deletion before actual deletion',
                0
            );
    }

    /**
     * Executes the command to find and permanently delete records which are marked as deleted
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // Make sure the _cli_ user is loaded
        Bootstrap::initializeBackendAuthentication();

        $io = new SymfonyStyle($input, $output);
        $io->title($this->getDescription());

        $startingPoint = 0;
        if ($input->hasOption('pid') && MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
            $startingPoint = MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
        }

        $depth = 1000;
        if ($input->hasOption('depth') && MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
            $depth = MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
        }

        $minimumAge = 0;
        if (MathUtility::canBeInterpretedAsInteger($input->getOption('min-age'))) {
            $minimumAge = MathUtility::forceIntegerInRange((int)$input->getOption('min-age'), 0);
        }
        $maximumTimestamp = $GLOBALS['EXEC_TIME'] - ($minimumAge * 86400);

        if ($io->isVerbose()) {
            $io->section('Searching the database now for deleted records.');
        }

        $dryRun = $input->hasOption('dry-run') && (bool)$input->getOption('dry-run') !== false;

        // find all records that should be deleted
        $deletedRecords = $this->findAllFlaggedRecordsInPage($startingPoint, $depth, $maximumTimestamp);

        if (!$io->isQuiet()) {
            $totalAmountOfTables = count($deletedRecords);
            $totalAmountOfRecords = 0;
            foreach ($deletedRecords as $tableName => $itemsInTable) {
                $totalAmountOfRecords += count($itemsInTable);

                if ($io->isVeryVerbose()) {
                    $io->writeln('Found ' . count($itemsInTable) . ' deleted records in table "' . $tableName . '".');
                }
            }
            $io->note('Found ' . $totalAmountOfRecords . ' records in ' . $totalAmountOfTables . ' database tables ready to be deleted.');
        }

        $io->section('Deletion process starting now.' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));

        // actually permanently delete them
        $this->deleteRecords($deletedRecords, $dryRun, $io);

        $io->success('All done!');
        return Command::SUCCESS;
    }

    /**
     * Recursive traversal of page tree to fetch all records marked as "deleted",
     * via option $GLOBALS[TCA][$tableName][ctrl][delete]
     * This also takes deleted versioned records into account.
     *
     * @param int $pageId the uid of the pages record (can also be 0)
     * @param int $depth The current depth of levels to go down
     * @param int $maximumTimestamp maximum value of records tstamp
     * @param array $deletedRecords the records that are already marked as deleted (used when going recursive)
     *
     * @return array the modified $deletedRecords array
     */
    protected function findAllFlaggedRecordsInPage(int $pageId, int $depth, int $maximumTimestamp, array $deletedRecords = []): array
    {
        $queryBuilderForPages = $this->connectionPool
            ->getQueryBuilderForTable('pages');
        $queryBuilderForPages->getRestrictions()->removeAll();

        if ($pageId > 0) {
            $pageQuery = $queryBuilderForPages
                ->count('uid')
                ->from('pages')
                ->where(
                    $queryBuilderForPages->expr()->and(
                        $queryBuilderForPages->expr()->eq(
                            'uid',
                            $queryBuilderForPages->createNamedParameter($pageId, Connection::PARAM_INT)
                        ),
                        $queryBuilderForPages->expr()->neq(
                            'deleted',
                            $queryBuilderForPages->createNamedParameter(0, Connection::PARAM_INT)
                        )
                    )
                );

            if ($maximumTimestamp > 0) {
                $pageQuery->andWhere(
                    $queryBuilderForPages->expr()->lt(
                        'tstamp',
                        $queryBuilderForPages->createNamedParameter($maximumTimestamp, Connection::PARAM_INT)
                    )
                );
            }

            // Register if page itself is deleted
            if ($pageQuery->executeQuery()->fetchOne() > 0) {
                $deletedRecords['pages'][$pageId] = $pageId;
            }
        }

        $databaseTables = $this->getTablesWithFlag('delete');
        $databaseTablesWithTstamp = $this->getTablesWithFlag('tstamp');
        // Traverse tables of records that belongs to page
        foreach ($databaseTables as $tableName => $deletedField) {
            // Select all records belonging to page
            $queryBuilder = $this->connectionPool
                ->getQueryBuilderForTable($tableName);

            $queryBuilder->getRestrictions()->removeAll();

            $query = $queryBuilder
                ->select('uid', $deletedField)
                ->from($tableName)
                ->where(
                    $queryBuilder->expr()->eq(
                        'pid',
                        $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
                    )
                );
            if (!isset($deletedRecords['pages'][$pageId])
                && $maximumTimestamp > 0
                && array_key_exists($tableName, $databaseTablesWithTstamp)
            ) {
                $query->andWhere(
                    $queryBuilder->expr()->lt(
                        $databaseTablesWithTstamp[$tableName],
                        $queryBuilder->createNamedParameter($maximumTimestamp, Connection::PARAM_INT)
                    )
                );
            }
            $result = $query->executeQuery();

            while ($recordOnPage = $result->fetchAssociative()) {
                // Register record as deleted
                if ($recordOnPage[$deletedField]) {
                    $deletedRecords[$tableName][$recordOnPage['uid']] = $recordOnPage['uid'];
                }
                // Add any versions of those records
                $versions = BackendUtility::selectVersionsOfRecord(
                    $tableName,
                    $recordOnPage['uid'],
                    'uid,t3ver_wsid,' . $deletedField,
                    null,
                    true
                ) ?: [];
                if (is_array($versions)) {
                    foreach ($versions as $verRec) {
                        // Mark as deleted
                        if (!($verRec['_CURRENT_VERSION'] ?? false) && $verRec[$deletedField]) {
                            $deletedRecords[$tableName][$verRec['uid']] = $verRec['uid'];
                        }
                    }
                }
            }
        }

        // Find subpages to root ID and go recursive
        if ($depth > 0) {
            $depth--;
            $result = $queryBuilderForPages
                ->select('uid')
                ->from('pages')
                ->where(
                    $queryBuilderForPages->expr()->eq('pid', $pageId)
                )
                ->orderBy('sorting')
                ->executeQuery();

            while ($subPage = $result->fetchAssociative()) {
                $deletedRecords = $this->findAllFlaggedRecordsInPage($subPage['uid'], $depth, $maximumTimestamp, $deletedRecords);
            }
        }

        // Add any versions of the page
        if ($pageId > 0) {
            $versions = BackendUtility::selectVersionsOfRecord(
                'pages',
                $pageId,
                'uid,t3ver_oid,t3ver_wsid',
                null,
                true
            ) ?: [];
            if (is_array($versions)) {
                foreach ($versions as $verRec) {
                    if (!($verRec['_CURRENT_VERSION'] ?? false)) {
                        $deletedRecords = $this->findAllFlaggedRecordsInPage($verRec['uid'], $depth, $maximumTimestamp, $deletedRecords);
                    }
                }
            }
        }

        return $deletedRecords;
    }

    /**
     * Fetches all tables registered in the TCA with a $flag and that are not pages (which are handled separately).
     */
    protected function getTablesWithFlag(string $flag): array
    {
        $tables = [];
        foreach ($GLOBALS['TCA'] as $tableName => $configuration) {
            if ($tableName !== 'pages' && isset($GLOBALS['TCA'][$tableName]['ctrl'][$flag])) {
                $tables[$tableName] = $GLOBALS['TCA'][$tableName]['ctrl'][$flag];
            }
        }
        ksort($tables);
        return $tables;
    }

    /**
     * Deletes records via DataHandler
     *
     * @param array $deletedRecords two level array with tables and uids
     * @param bool $dryRun check if the records should NOT be deleted (use --dry-run to avoid)
     */
    protected function deleteRecords(array $deletedRecords, bool $dryRun, SymfonyStyle $io): void
    {
        // Putting "pages" table in the bottom
        if (isset($deletedRecords['pages'])) {
            $_pages = $deletedRecords['pages'];
            unset($deletedRecords['pages']);
            // To delete sub pages first assuming they are accumulated from top of page tree.
            $deletedRecords['pages'] = array_reverse($_pages);
        }

        // set up the data handler instance
        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $dataHandler->start([], []);

        // Loop through all tables and their records
        foreach ($deletedRecords as $table => $list) {
            if ($io->isVerbose()) {
                $io->writeln('Flushing ' . count($list) . ' deleted records from table "' . $table . '"');
            }
            foreach ($list as $uid) {
                if ($io->isVeryVerbose()) {
                    $io->writeln('Flushing record "' . $table . ':' . $uid . '"');
                }
                if (!$dryRun) {
                    // Notice, we are deleting pages with no regard to subpages/subrecords - we do this since they
                    // should also be included in the set of deleted pages of course (no un-deleted record can exist
                    // under a deleted page...)
                    $dataHandler->deleteRecord($table, $uid, true, true);
                    // Return errors if any:
                    if (!empty($dataHandler->errorLog)) {
                        $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
                        $io->error($errorMessage);
                    } elseif (!$io->isQuiet()) {
                        $io->writeln('Permanently deleted record "' . $table . ':' . $uid . '".');
                    }
                }
            }
        }
    }
}