├── registration.php ├── etc ├── module.xml ├── config.xml ├── adminhtml │ └── system.xml └── di.xml ├── DataObject └── GalleryValue.php ├── LICENSE ├── Console ├── ProgressIndicator.php ├── Command │ ├── RemoveObsoleteDatabaseEntries.php │ ├── RemoveUnusedImageFiles.php │ ├── RemoveCorruptResizedFiles.php │ └── RemoveUnusedCacheHashDirectories.php └── UserInteraction.php ├── Config └── ConfigReader.php ├── Logger └── Logger.php ├── composer.json ├── Deleter ├── DatabaseGalleryDeleter.php └── MediaDeleter.php ├── Stats └── FileStatsCalculator.php ├── Finder ├── ObsoleteDatabaseEntriesFinder.php ├── CorruptResizedFilesFinder.php ├── UnusedCacheHashDirectoriesFinder.php └── UnusedFilesFinder.php ├── Service └── HyvaThemeFallbackStoreThemeResolver.php └── README.md /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /etc/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | webp,avif 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /DataObject/GalleryValue.php: -------------------------------------------------------------------------------- 1 | valueId = $valueId; 15 | $this->value = $value; 16 | } 17 | 18 | public function getValueId(): int 19 | { 20 | return $this->valueId; 21 | } 22 | 23 | public function __toString() 24 | { 25 | return sprintf( 26 | '[valueId %s] %s', 27 | $this->valueId, 28 | $this->value 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /etc/adminhtml/system.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | webp,avif
10 | The module will not remove dynamically generated files with these alternative extensions in case the original uploaded image is still being used]]>
11 |
12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Baldwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Console/ProgressIndicator.php: -------------------------------------------------------------------------------- 1 | output = $output; 21 | $this->progressBar = new ProgressBar($output); 22 | } 23 | 24 | public function start(string $message): void 25 | { 26 | $this->progressBar->setFormat(" %message%:\n %current% [%bar%]"); 27 | $this->progressBar->setMessage($message); 28 | $this->progressBar->start(); 29 | } 30 | 31 | public function setMax(int $max): void 32 | { 33 | $this->progressBar->start($max); 34 | } 35 | 36 | public function advance(): void 37 | { 38 | $this->progressBar->advance(); 39 | } 40 | 41 | public function stop(): void 42 | { 43 | $this->progressBar->finish(); 44 | $this->output->writeln(''); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Config/ConfigReader.php: -------------------------------------------------------------------------------- 1 | scopeConfig = $scopeConfig; 19 | } 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getAlternativeExtensions(): array 25 | { 26 | $extensions = $this->scopeConfig->getValue( 27 | self::CONFIG_PATH_ALT_EXTENSIONS 28 | ); 29 | 30 | if (!is_string($extensions)) { 31 | return []; 32 | } 33 | 34 | // array_diff is to remove empty strings from the array 35 | $extensions = array_diff(explode(',', trim($extensions)), ['']); 36 | 37 | // remove spaces and dots in case somebody did add those 38 | $extensions = array_map(function (string $ext) { 39 | return trim(trim($ext), '.'); 40 | }, $extensions); 41 | 42 | return $extensions; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Logger/Logger.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 17 | } 18 | 19 | public function logPathRemoval(string $path): void 20 | { 21 | $this->logger->info(sprintf('Removed path "%s"', $path)); 22 | } 23 | 24 | public function logDbRowRemoval(string $rowRepresentation, string $dbTable): void 25 | { 26 | $this->logger->info(sprintf('Removed db value "%s" from table %s', $rowRepresentation, $dbTable)); 27 | } 28 | 29 | public function logNoActionTaken(): void 30 | { 31 | $this->logger->info('Nothing found to cleanup, all is good!'); 32 | } 33 | 34 | public function logFinalSummary(int $numberOfFilesRemoved, string $formattedBytesRemoved): void 35 | { 36 | $this->logger->info(sprintf( 37 | '-- Summary: removed %d files in total which cleared up %s of diskspace', 38 | $numberOfFilesRemoved, 39 | $formattedBytesRemoved 40 | )); 41 | } 42 | 43 | public function logFinalDbSummary(int $numberOfDbRowsRemoved, string $dbTable): void 44 | { 45 | $this->logger->info(sprintf( 46 | '-- Summary: removed %d rows in the %s database table', 47 | $numberOfDbRowsRemoved, 48 | $dbTable 49 | )); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baldwin/magento2-module-image-cleanup", 3 | "description": "Magento 2 module which can cleanup old image files that are no longer being used", 4 | "license": "MIT", 5 | "type": "magento2-module", 6 | "authors": [ 7 | { 8 | "name": "Pieter Hoste", 9 | "email": "pieter@baldwin.be", 10 | "role": "Problem Solver" 11 | } 12 | ], 13 | "require": { 14 | "php": "~7.3.0 || ~7.4.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", 15 | "magento/framework": "^102.0.4 || ^103.0", 16 | "magento/module-catalog": "^103.0.4 || ^104.0", 17 | "magento/module-eav": "^102.0.4", 18 | "magento/module-store": "^101.0.4", 19 | "magento/module-theme": "^101.0.4", 20 | "symfony/console": "^4.0 || ^5.0 || ^6.0" 21 | }, 22 | "require-dev": { 23 | "bamarni/composer-bin-plugin": "^1.7", 24 | "ergebnis/composer-normalize": "^2.17" 25 | }, 26 | "repositories": [ 27 | { 28 | "type": "composer", 29 | "url": "https://repo.magento.com/" 30 | } 31 | ], 32 | "autoload": { 33 | "psr-4": { 34 | "Baldwin\\ImageCleanup\\": "" 35 | }, 36 | "files": [ 37 | "registration.php" 38 | ] 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "bamarni/composer-bin-plugin": true, 43 | "ergebnis/composer-normalize": true, 44 | "magento/composer-dependency-version-audit-plugin": true 45 | }, 46 | "sort-packages": true 47 | }, 48 | "extra": { 49 | "bamarni-bin": { 50 | "bin-links": false, 51 | "forward-command": true 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Baldwin\ImageCleanup\Console\Command\RemoveCorruptResizedFiles 7 | Baldwin\ImageCleanup\Console\Command\RemoveObsoleteDatabaseEntries 8 | Baldwin\ImageCleanup\Console\Command\RemoveUnusedCacheHashDirectories 9 | Baldwin\ImageCleanup\Console\Command\RemoveUnusedImageFiles 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Baldwin\ImageCleanup\Service\HyvaThemeFallbackStoreThemeResolver 18 | 19 | 20 | 21 | 22 | 23 | 24 | /var/log/baldwin-imagecleanup.log 25 | 26 | 27 | 28 | 29 | 30 | Baldwin::ImageCleanup 31 | 32 | Baldwin\ImageCleanup\Log\Handler 33 | 34 | 35 | 36 | 37 | 38 | 39 | Baldwin\ImageCleanup\Log\Logger 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Deleter/DatabaseGalleryDeleter.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $deletedValueIds = []; 19 | 20 | /** @var int */ 21 | private $deletedValuesCount = 0; 22 | 23 | public function __construct( 24 | ResourceConnection $resource, 25 | Logger $logger 26 | ) { 27 | $this->resource = $resource; 28 | $this->logger = $logger; 29 | } 30 | 31 | /** 32 | * @param array $values 33 | */ 34 | public function deleteGalleryValues(array $values): void 35 | { 36 | $this->resetState(); 37 | 38 | $valueIds = array_map( 39 | function (GalleryValue $value) { 40 | return $value->getValueId(); 41 | }, 42 | $values 43 | ); 44 | 45 | $valueIdChunks = array_chunk($valueIds, 5000); 46 | foreach ($valueIdChunks as $valueIdChunk) { 47 | $deleteResult = $this->resource->getConnection()->delete( 48 | $this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE), 49 | [ 50 | $this->resource->getConnection()->quoteInto('value_id IN (?)', $valueIdChunk), 51 | ] 52 | ); 53 | 54 | $this->deletedValuesCount += $deleteResult; 55 | foreach ($valueIdChunk as $valueId) { 56 | $prefixedValueId = sprintf('valueId: %d', $valueId); 57 | 58 | $this->deletedValueIds[] = $prefixedValueId; 59 | $this->logger->logDbRowRemoval($prefixedValueId, GalleryResourceModel::GALLERY_TABLE); 60 | } 61 | } 62 | } 63 | 64 | private function resetState(): void 65 | { 66 | $this->deletedValueIds = []; 67 | $this->deletedValuesCount = 0; 68 | } 69 | 70 | /** 71 | * @return array 72 | */ 73 | public function getDeletedValues(): array 74 | { 75 | return $this->deletedValueIds; 76 | } 77 | 78 | public function getNumberOfValuesDeleted(): int 79 | { 80 | return $this->deletedValuesCount; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Stats/FileStatsCalculator.php: -------------------------------------------------------------------------------- 1 | filesystemFileDriver = $filesystemFileDriver; 24 | } 25 | 26 | public function resetStats(): void 27 | { 28 | $this->numberOfFiles = 0; 29 | $this->totalSizeInBytes = 0; 30 | } 31 | 32 | public function calculateStatsForPath(string $path): void 33 | { 34 | if ($this->skipCalculation === true) { 35 | return; 36 | } 37 | 38 | if ($this->filesystemFileDriver->isDirectory($path)) { 39 | $fileIterator = new \RecursiveIteratorIterator( 40 | new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS) 41 | ); 42 | 43 | /** @var \SplFileInfo $file */ 44 | foreach ($fileIterator as $file) { 45 | if ($file->isFile()) { 46 | ++$this->numberOfFiles; 47 | $this->totalSizeInBytes += $file->getSize(); 48 | } 49 | } 50 | } elseif ($this->filesystemFileDriver->isFile($path)) { 51 | $file = new \SplFileInfo($path); 52 | 53 | ++$this->numberOfFiles; 54 | $this->totalSizeInBytes += $file->getSize(); 55 | } 56 | } 57 | 58 | /** 59 | * @param array $paths 60 | */ 61 | public function calculateStatsForPaths(array $paths): void 62 | { 63 | if ($this->skipCalculation === true) { 64 | return; 65 | } 66 | 67 | foreach ($paths as $path) { 68 | $this->calculateStatsForPath($path); 69 | } 70 | } 71 | 72 | public function setSkipCalculation(bool $skip): void 73 | { 74 | $this->skipCalculation = $skip; 75 | } 76 | 77 | public function canCalculate(): bool 78 | { 79 | return !$this->skipCalculation; 80 | } 81 | 82 | public function getNumberOfFiles(): int 83 | { 84 | return $this->numberOfFiles; 85 | } 86 | 87 | public function getTotalSizeInBytes(): int 88 | { 89 | return $this->totalSizeInBytes; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Console/Command/RemoveObsoleteDatabaseEntries.php: -------------------------------------------------------------------------------- 1 | userInteraction = $userInteraction; 28 | $this->dbGalleryDeleter = $dbGalleryDeleter; 29 | $this->obsoleteDbEntriesFinder = $obsoleteDbEntriesFinder; 30 | 31 | parent::__construct(); 32 | } 33 | 34 | protected function configure(): void 35 | { 36 | $this->setName('catalog:images:remove-obsolete-db-entries'); 37 | $this->setDescription( 38 | sprintf( 39 | 'Removes values from the %s db table which are no longer needed.', 40 | GalleryResourceModel::GALLERY_TABLE 41 | ) 42 | ); 43 | 44 | parent::configure(); 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output) 48 | { 49 | $entries = $this->obsoleteDbEntriesFinder->find(); 50 | 51 | $accepted = $this->userInteraction->showDbValuesToDeleteAndAskForConfirmation( 52 | array_map('strval', $entries), 53 | GalleryResourceModel::GALLERY_TABLE, 54 | $input, 55 | $output 56 | ); 57 | if ($accepted) { 58 | $this->dbGalleryDeleter->deleteGalleryValues($entries); 59 | $deletedValues = $this->dbGalleryDeleter->getDeletedValues(); 60 | $numberOfValuesDeleted = $this->dbGalleryDeleter->getNumberOfValuesDeleted(); 61 | 62 | $this->userInteraction->showFinalDbInfo( 63 | $deletedValues, 64 | $numberOfValuesDeleted, 65 | GalleryResourceModel::GALLERY_TABLE, 66 | $output 67 | ); 68 | } 69 | 70 | return Cli::RETURN_SUCCESS; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Finder/ObsoleteDatabaseEntriesFinder.php: -------------------------------------------------------------------------------- 1 | resource = $resource; 23 | $this->attributeRepository = $attributeRepository; 24 | } 25 | 26 | /** 27 | * @return array 28 | */ 29 | public function find(): array 30 | { 31 | $values = []; 32 | 33 | // SQL queries that will find obsolete entries: 34 | // 35 | // 1) SELECT * FROM catalog_product_entity_media_gallery 36 | // WHERE media_type = 'image' 37 | // AND value_id NOT IN (SELECT DISTINCT value_id FROM catalog_product_entity_media_gallery_value_to_entity); 38 | // 2) SELECT * FROM catalog_product_entity_media_gallery cpemg 39 | // LEFT JOIN catalog_product_entity_media_gallery_value_to_entity cpemgvte 40 | // ON cpemg.value_id = cpemgvte.value_id 41 | // WHERE cpemgvte.value_id IS NULL AND cpemg.media_type = 'image'; 42 | // 43 | // the second one is about 5 to 50 times faster on a db with 44 | // 218043 entries in catalog_product_entity_media_gallery and 45 | // 101860 entries in catalog_product_entity_media_gallery_value_to_entity 46 | 47 | $mediaGalleryAttr = $this->attributeRepository->get(ProductModel::ENTITY, 'media_gallery'); 48 | $mediaGalleryId = $mediaGalleryAttr->getAttributeId(); 49 | 50 | $entriesQuery = $this->resource->getConnection()->select() 51 | ->from( 52 | ['cpemg' => $this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE)], 53 | ['value_id', 'value'] 54 | ) 55 | ->joinLeft( 56 | ['cpemgvte' => $this->resource->getTableName(GalleryResourceModel::GALLERY_VALUE_TO_ENTITY_TABLE)], 57 | 'cpemg.value_id = cpemgvte.value_id', 58 | '' 59 | ) 60 | ->where( 61 | 'cpemgvte.value_id IS NULL AND cpemg.media_type = "image" AND cpemg.attribute_id = :media_gallery_id' 62 | ) 63 | ; 64 | 65 | $entries = $this->resource->getConnection()->fetchAll($entriesQuery, ['media_gallery_id' => $mediaGalleryId]); 66 | 67 | foreach ($entries as $entry) { 68 | $values[] = new GalleryValue((int) $entry['value_id'], $entry['value']); 69 | } 70 | 71 | return $values; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Console/Command/RemoveUnusedImageFiles.php: -------------------------------------------------------------------------------- 1 | userInteraction = $userInteraction; 31 | $this->progressIndicator = $progressIndicator; 32 | $this->mediaDeleter = $mediaDeleter; 33 | $this->unusedFilesFinder = $unusedFilesFinder; 34 | 35 | parent::__construct(); 36 | } 37 | 38 | protected function configure(): void 39 | { 40 | $this->setName('catalog:images:remove-unused-files'); 41 | $this->setDescription( 42 | 'Remove unused product image files from the filesystem. ' 43 | . 'We compare the data that\'s in the database with the files on disk and remove the ones that don\'t match' 44 | ); 45 | $this->addOption( 46 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS, 47 | null, 48 | InputOption::VALUE_NONE, 49 | 'Skip calculating and outputting stats (filesizes, number of files, ...), ' 50 | . 'this can speed up the command in case it runs slowly.' 51 | ); 52 | 53 | parent::configure(); 54 | } 55 | 56 | protected function execute(InputInterface $input, OutputInterface $output) 57 | { 58 | $this->progressIndicator->init($output); 59 | 60 | $files = $this->unusedFilesFinder->find(); 61 | 62 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($files, $input, $output); 63 | if ($accepted) { 64 | $this->mediaDeleter->deletePaths($files); 65 | $deletedPaths = $this->mediaDeleter->getDeletedPaths(); 66 | $skippedPaths = $this->mediaDeleter->getSkippedPaths(); 67 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted(); 68 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted(); 69 | 70 | $this->userInteraction->showFinalInfo( 71 | $deletedPaths, 72 | $skippedPaths, 73 | $numberOfFilesRemoved, 74 | $bytesRemoved, 75 | $output 76 | ); 77 | } 78 | 79 | return Cli::RETURN_SUCCESS; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Console/Command/RemoveCorruptResizedFiles.php: -------------------------------------------------------------------------------- 1 | userInteraction = $userInteraction; 31 | $this->progressIndicator = $progressIndicator; 32 | $this->mediaDeleter = $mediaDeleter; 33 | $this->corruptResizedFilesFinder = $corruptResizedFilesFinder; 34 | 35 | parent::__construct(); 36 | } 37 | 38 | protected function configure(): void 39 | { 40 | $this->setName('catalog:images:remove-corrupt-resized-files'); 41 | $this->setDescription( 42 | 'Remove corrupt resized image files from the filesystem. ' 43 | . 'Magento will re-generate them again afterwards' 44 | ); 45 | $this->addOption( 46 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS, 47 | null, 48 | InputOption::VALUE_NONE, 49 | 'Skip calculating and outputting stats (filesizes, number of files, ...), ' 50 | . 'this can speed up the command in case it runs slowly.' 51 | ); 52 | 53 | parent::configure(); 54 | } 55 | 56 | protected function execute(InputInterface $input, OutputInterface $output) 57 | { 58 | $this->progressIndicator->init($output); 59 | 60 | $files = $this->corruptResizedFilesFinder->find(); 61 | 62 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($files, $input, $output); 63 | if ($accepted) { 64 | $this->mediaDeleter->deletePaths($files); 65 | $deletedPaths = $this->mediaDeleter->getDeletedPaths(); 66 | $skippedPaths = $this->mediaDeleter->getSkippedPaths(); 67 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted(); 68 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted(); 69 | 70 | $this->userInteraction->showFinalInfo( 71 | $deletedPaths, 72 | $skippedPaths, 73 | $numberOfFilesRemoved, 74 | $bytesRemoved, 75 | $output 76 | ); 77 | } 78 | 79 | return Cli::RETURN_SUCCESS; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Service/HyvaThemeFallbackStoreThemeResolver.php: -------------------------------------------------------------------------------- 1 | themeProvider = $themeProvider; 30 | $this->appEmulation = $appEmulation; 31 | } 32 | 33 | public function setIsActive(bool $isActive): void 34 | { 35 | $this->isActive = $isActive; 36 | } 37 | 38 | public function getThemes(StoreInterface $store): array 39 | { 40 | $themeIds = []; 41 | 42 | if ($this->isActive && class_exists(HyvaThemeFallbackConfig::class)) { 43 | $hyvaFallbackThemeId = $this->getHyvaFallbackThemeId($store); 44 | if ($hyvaFallbackThemeId !== null) { 45 | $themeIds[] = $hyvaFallbackThemeId; 46 | } 47 | } 48 | 49 | return $themeIds; 50 | } 51 | 52 | private function getHyvaFallbackThemeId(StoreInterface $store): ?int 53 | { 54 | $themeId = null; 55 | $hyvaThemeFallbackConfig = $this->getHyvaThemeFallbackConfig(); 56 | 57 | // need to emulate frontend storeview 58 | // because we can't pass on the incoming store param to the calls to hyvaThemeFallbackConfig unfortunately 59 | $this->appEmulation->startEnvironmentEmulation($store->getId()); 60 | 61 | if ($hyvaThemeFallbackConfig->isEnabled()) { 62 | $fallbackThemePath = $hyvaThemeFallbackConfig->getThemeFullPath(); 63 | $fallbackTheme = $this->themeProvider->getThemeByFullPath($fallbackThemePath); 64 | 65 | $fallbackThemeId = $fallbackTheme->getId(); 66 | 67 | if ($fallbackThemeId !== null && is_numeric($fallbackThemeId)) { 68 | $themeId = (int) $fallbackThemeId; 69 | } 70 | } 71 | 72 | $this->appEmulation->stopEnvironmentEmulation(); 73 | 74 | return $themeId; 75 | } 76 | 77 | /** 78 | * Can't inject in constructor because it's a soft dependency 79 | */ 80 | private function getHyvaThemeFallbackConfig(): HyvaThemeFallbackConfig 81 | { 82 | if ($this->hyvaThemeFallbackConfig === null) { 83 | /** @var HyvaThemeFallbackConfig $hyvaThemeFallbackConfig */ 84 | $hyvaThemeFallbackConfig = ObjectManager::getInstance()->get(HyvaThemeFallbackConfig::class); 85 | 86 | $this->hyvaThemeFallbackConfig = $hyvaThemeFallbackConfig; 87 | } 88 | 89 | return $this->hyvaThemeFallbackConfig; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Deleter/MediaDeleter.php: -------------------------------------------------------------------------------- 1 | */ 22 | private $deletedPaths = []; 23 | /** @var array */ 24 | private $skippedPaths = []; 25 | 26 | public function __construct( 27 | FileStatsCalculator $fileStatsCalculator, 28 | Filesystem $filesystem, 29 | FilesystemFileDriver $filesystemFileDriver, 30 | Logger $logger 31 | ) { 32 | $this->fileStatsCalculator = $fileStatsCalculator; 33 | $this->filesystem = $filesystem; 34 | $this->filesystemFileDriver = $filesystemFileDriver; 35 | $this->logger = $logger; 36 | } 37 | 38 | /** 39 | * @param array $paths 40 | */ 41 | public function deletePaths(array $paths): void 42 | { 43 | $this->resetState(); 44 | 45 | $mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); 46 | $mediaDirectoryPath = $this->filesystemFileDriver->getRealPath($mediaDirectory->getAbsolutePath()); 47 | if (!is_string($mediaDirectoryPath)) { 48 | throw new FileSystemException( 49 | __('Can\'t find media directory path: "%1"', $mediaDirectory->getAbsolutePath()) 50 | ); 51 | } 52 | 53 | foreach ($paths as $path) { 54 | $regex = '#^' . preg_quote($mediaDirectoryPath) . '#'; 55 | if (preg_match($regex, $path) !== 1) { 56 | $this->skippedPaths[] = $path; 57 | } else { 58 | $relativePath = preg_replace($regex, '', $path); 59 | 60 | $this->fileStatsCalculator->calculateStatsForPath($path); 61 | $this->deletedPaths[] = $path; 62 | $mediaDirectory->delete($relativePath); 63 | 64 | $this->logger->logPathRemoval($path); 65 | } 66 | } 67 | } 68 | 69 | private function resetState(): void 70 | { 71 | $this->skippedPaths = []; 72 | $this->deletedPaths = []; 73 | 74 | $this->fileStatsCalculator->resetStats(); 75 | } 76 | 77 | /** 78 | * @return array 79 | */ 80 | public function getDeletedPaths(): array 81 | { 82 | return $this->deletedPaths; 83 | } 84 | 85 | /** 86 | * @return array 87 | */ 88 | public function getSkippedPaths(): array 89 | { 90 | return $this->skippedPaths; 91 | } 92 | 93 | public function getNumberOfFilesDeleted(): int 94 | { 95 | return $this->fileStatsCalculator->getNumberOfFiles(); 96 | } 97 | 98 | public function getBytesDeleted(): int 99 | { 100 | return $this->fileStatsCalculator->getTotalSizeInBytes(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Console/Command/RemoveUnusedCacheHashDirectories.php: -------------------------------------------------------------------------------- 1 | appState = $appState; 35 | $this->userInteraction = $userInteraction; 36 | $this->mediaDeleter = $mediaDeleter; 37 | $this->unusedCacheHashDirFinder = $unusedCacheHashDirFinder; 38 | $this->hyvaThemeFallbackStoreThemeResolver = $hyvaThemeFallbackStoreThemeResolver; 39 | 40 | parent::__construct(); 41 | } 42 | 43 | protected function configure(): void 44 | { 45 | $this->setName('catalog:images:remove-unused-hash-directories'); 46 | $this->setDescription( 47 | 'Remove unused resized hash directories (like: pub/media/catalog/product/cache/xxyyzz). ' 48 | . 'These directories can be a leftover from older Magento versions, or from image definitions that got ' 49 | . 'removed from the etc/view.xml file of a custom theme for example.' 50 | ); 51 | $this->addOption( 52 | UserInteraction::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS, 53 | null, 54 | InputOption::VALUE_NONE, 55 | 'Skip calculating and outputting stats (filesizes, number of files, ...), ' 56 | . 'this can speed up the command in case it runs slowly.' 57 | ); 58 | 59 | parent::configure(); 60 | } 61 | 62 | protected function execute(InputInterface $input, OutputInterface $output) 63 | { 64 | // needed to avoid 'Area code is not set' 65 | // mimicking same area as core magento (global) from the catalog:images:resize command 66 | $this->appState->setAreaCode(AppArea::AREA_GLOBAL); 67 | 68 | $this->hyvaThemeFallbackStoreThemeResolver->setIsActive(true); 69 | 70 | $directories = $this->unusedCacheHashDirFinder->find(); 71 | 72 | $this->hyvaThemeFallbackStoreThemeResolver->setIsActive(false); 73 | 74 | $accepted = $this->userInteraction->showPathsToDeleteAndAskForConfirmation($directories, $input, $output); 75 | if ($accepted) { 76 | $this->mediaDeleter->deletePaths($directories); 77 | $deletedPaths = $this->mediaDeleter->getDeletedPaths(); 78 | $skippedPaths = $this->mediaDeleter->getSkippedPaths(); 79 | $numberOfFilesRemoved = $this->mediaDeleter->getNumberOfFilesDeleted(); 80 | $bytesRemoved = $this->mediaDeleter->getBytesDeleted(); 81 | 82 | $this->userInteraction->showFinalInfo( 83 | $deletedPaths, 84 | $skippedPaths, 85 | $numberOfFilesRemoved, 86 | $bytesRemoved, 87 | $output 88 | ); 89 | } 90 | 91 | return Cli::RETURN_SUCCESS; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Finder/CorruptResizedFilesFinder.php: -------------------------------------------------------------------------------- 1 | progressIndicator = $progressIndicator; 22 | $this->filesystem = $filesystem; 23 | } 24 | 25 | /** 26 | * @return array 27 | */ 28 | public function find(): array 29 | { 30 | $this->progressIndicator->start('Searching for corrupt files'); 31 | 32 | $corruptFiles = $this->getCorruptFilesOnDisk(); 33 | 34 | $this->progressIndicator->stop(); 35 | 36 | return $corruptFiles; 37 | } 38 | 39 | /** 40 | * @return array 41 | */ 42 | private function getCorruptFilesOnDisk(): array 43 | { 44 | $corruptFiles = []; 45 | 46 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); 47 | $resizedImageDirectories = $this->getResizedImageDirectories($mediaDirectory); 48 | $this->progressIndicator->setMax(count($resizedImageDirectories)); 49 | 50 | foreach ($resizedImageDirectories as $resizedImageDir) { 51 | $fileIterator = new \RecursiveIteratorIterator( 52 | new \RecursiveDirectoryIterator( 53 | $mediaDirectory->getAbsolutePath($resizedImageDir), 54 | \FilesystemIterator::SKIP_DOTS 55 | ) 56 | ); 57 | 58 | /** @var \SplFileInfo $file */ 59 | foreach ($fileIterator as $file) { 60 | if ($file->isFile() && $this->isCorruptFile($file)) { 61 | $corruptFiles[] = $file->getRealPath(); 62 | } 63 | } 64 | 65 | $this->progressIndicator->advance(); 66 | } 67 | 68 | return $corruptFiles; 69 | } 70 | 71 | /** 72 | * @return array 73 | */ 74 | private function getResizedImageDirectories(ReadInterface $mediaDirectory): array 75 | { 76 | // find all cached hash subdirectories, 77 | // so the ones starting with a single letter in their directory name 78 | $resizedImageDirectories = []; 79 | 80 | $cacheDir = 'catalog/product/cache'; 81 | if ($mediaDirectory->isExist($cacheDir)) { 82 | /** @var array */ 83 | $hashDirectories = $mediaDirectory->read($cacheDir); 84 | foreach ($hashDirectories as $hashDir) { 85 | if ($mediaDirectory->isDirectory($hashDir)) { 86 | $resizedImageDirectories[] = $mediaDirectory->read($hashDir); 87 | } 88 | } 89 | } 90 | $resizedImageDirectories = array_merge([], ...$resizedImageDirectories); 91 | 92 | // only allow directories that have one character as lowest path 93 | $resizedImageDirectories = array_filter( 94 | $resizedImageDirectories, 95 | function (string $path) use ($mediaDirectory) { 96 | return $mediaDirectory->isDirectory($path) && preg_match('#/.{1}$#', $path) === 1; 97 | } 98 | ); 99 | 100 | return $resizedImageDirectories; 101 | } 102 | 103 | private function isCorruptFile(\SplFileInfo $file): bool 104 | { 105 | $size = $file->getSize(); 106 | 107 | // no idea if a false value means corrupt, but let's choose not to for now 108 | if ($size === false) { 109 | return false; 110 | } 111 | 112 | // check if filesize is 0, if 0, file is corrupt 113 | if ($size === 0) { 114 | return true; 115 | } 116 | 117 | // if imagesize can't be determined, we can assume the image is corrupt 118 | // phpcs:ignore Magento2.Functions.DiscouragedFunction.DiscouragedWithAlternative 119 | $imageSizeResult = getimagesize($file->getRealPath()); 120 | if ($imageSizeResult === false) { 121 | return true; 122 | } 123 | 124 | return false; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Cleanup module for Magento 2 2 | 3 | ## Purpose 4 | 5 | When adding products in your webshop, eventually you'll also have to delete some of those products. 6 | But sometimes Magento doesn't remove images associated with the products that you delete. 7 | So you'll have to manually delete them from time to time from disk, which is hard to do manually. 8 | This module gives you some options to delete those lingering unused images from the disk so you can recover some diskspace on your server. 9 | 10 | ## Implemented Features 11 | 12 | - It can find product image files on disk that are not referenced in the database and remove them 13 | - It can do the same for the resized (cached) versions of those images 14 | - It tries to not delete dynamically generated images files (like `webp` of `avif` files) if the original file is still being used, see [configuration](#configuration) 15 | - It can detect entire unused resized (cached) directories that are no longer valid and remove them with all the files in there, see [below](#documentation-about-resizedcached-directories) 16 | - It can detect and remove obsolete values in the `catalog_product_entity_media_gallery` database table 17 | - It can find resized product image files that are corrupt and remove them 18 | 19 | ## Watch out 20 | 21 | - The module will always first output what it will delete, make sure you check the entire list before confirming, so that you aren't removing files you don't want to remove. Do **not** _test_ this module on a production environment first before you fully understand what it will do! 22 | - This module hasn't been tested when your Magento shop is configured to store image files in the database, your mileage may vary when you use that way of working. Feel free to open issues in case any occur, and we'll see if we can fix something... 23 | 24 | ## Compatibility 25 | 26 | - This module should work with Magento 2.3.4 or higher 27 | - The module should be compatible with PHP 7.3, 7.4, 8.1, 8.2, 8.3 and 8.4 28 | 29 | ## Installation 30 | 31 | You can use composer to install this module: 32 | 33 | ```sh 34 | composer require baldwin/magento2-module-image-cleanup 35 | ``` 36 | 37 | Or download the code and put all the files in the directory `app/code/Baldwin/ImageCleanup` 38 | 39 | After which you can then activate it in Magento using: 40 | 41 | ```sh 42 | bin/magento setup:upgrade 43 | ``` 44 | 45 | ## Usage 46 | 47 | There are 4 command line commands you can use execute: 48 | 49 | - `bin/magento catalog:images:remove-obsolete-db-entries` 50 | - `bin/magento catalog:images:remove-unused-hash-directories` 51 | - `bin/magento catalog:images:remove-unused-files` 52 | - `bin/magento catalog:images:remove-corrupt-resized-files` 53 | 54 | There are some extra options for some of these commands: 55 | 56 | ``` 57 | --no-stats Skip calculating and outputting stats (filesizes, number of files, ...), this can speed up the command in case it runs slowly. 58 | -n, --no-interaction Do not ask any interactive question 59 | ``` 60 | 61 | The `-n` option can be used if you want to setup a cronjob to regularly call these cleanup commands, it will not ask for confirmation before removing files, and will just assume you said 'yes, go ahead' (which can be dangerous!) 62 | 63 | The module will output all the things it deleted in a log file `{magento-project}/var/log/baldwin-imagecleanup.log` so you can inspect it later in case you want to figure out why something got removed. 64 | 65 | For optimal & fastest cleanup, it's advised to run the commands in this order: 66 | 67 | 1. `bin/magento catalog:images:remove-obsolete-db-entries` 68 | 2. `bin/magento catalog:images:remove-unused-hash-directories` 69 | 3. `bin/magento catalog:images:remove-unused-files` 70 | 4. `bin/magento catalog:images:remove-corrupt-resized-files` 71 | 72 | If you don't run these in this order, it might mean you'll need to run some of them a second time for them to find more things to cleanup or it might mean that they'll take longer then needed. 73 | 74 | 75 | ## Configuration 76 | 77 | There is a configuration section in the backoffice under: Stores > Configuration > Catalog > Catalog > Product Image Cleanup Settings 78 | 79 | - **List of dynamically generated image file extensions**: Some Magento shops might have modules installed to dynamically generate `webp` or `avif` image files out of the original product image files. These files are usually not referenced in the database of Magento so by specifying those file extensions in the configuration, we can prevent them from being deleted accidentally. The module will still be able to remove those type of files when the original file is no longer referenced in the database. 80 | This feature only works properly when the dynamically generated image files use the same filename as the original file, so they can only be different in the file extension being used (either replaced or appended). 81 | 82 | ## Documentation about resized/cached directories 83 | 84 | Magento saves resized product images in certain directories in `pub/media/catalog/product/cache` 85 | The directory names are basically an md5 hash of a bunch of parameters like: width, height, background-color, quality, rotation, ... (which tend to be defined in the `etc/view.xml` file of themes) 86 | Sometimes, Magento tweaks how the hash gets calculated in certain newer versions of Magento, or your theme changes some parameter which both can make those hashes no longer being used. 87 | 88 | This module has the option to detect such directories and can remove them together with all the files in there. 89 | 90 | ## Note to self 91 | 92 | In our class `Baldwin\ImageCleanup\Finder\UnusedCacheHashDirectoriesFinder`, we borrowed some code from core Magento that was private and not easily callable. We made only very slight changes to deal with coding standards and static analysis, but it's mostly the same as the original source. These pieces of code were based on code that didn't really change since Magento 2.3.4. 93 | 94 | It's important that we check with every single new Magento version that gets released, that the code in `Magento\MediaStorage\Service\ImageResize` doesn't change in such a way that we need to adapt our own implementation. 95 | 96 | So this is something that needs to be double checked with every new Magento release. 97 | -------------------------------------------------------------------------------- /Console/UserInteraction.php: -------------------------------------------------------------------------------- 1 | fileStatsCalculator = $fileStatsCalculator; 26 | $this->logger = $logger; 27 | } 28 | 29 | /** 30 | * @param array $paths 31 | */ 32 | public function showPathsToDeleteAndAskForConfirmation( 33 | array $paths, 34 | InputInterface $input, 35 | OutputInterface $output 36 | ): bool { 37 | $skipCalculationOption = (bool) $input->getOption(self::CONSOLE_OPTION_TO_SKIP_GENERATING_STATS); 38 | $this->fileStatsCalculator->setSkipCalculation($skipCalculationOption); 39 | 40 | $output->writeln(''); 41 | 42 | if ($paths === []) { 43 | $output->writeln('No paths found to cleanup, all is good!'); 44 | $this->logger->logNoActionTaken(); 45 | 46 | return false; 47 | } 48 | 49 | if ($input->isInteractive()) { 50 | $this->displayPaths($paths, $output); 51 | $this->calculateAndDisplayStats($paths, $output); 52 | } 53 | 54 | return $this->askForConfirmation($input, $output); 55 | } 56 | 57 | /** 58 | * @param array $values 59 | */ 60 | public function showDbValuesToDeleteAndAskForConfirmation( 61 | array $values, 62 | string $dbTable, 63 | InputInterface $input, 64 | OutputInterface $output 65 | ): bool { 66 | $output->writeln(''); 67 | 68 | if ($values === []) { 69 | $output->writeln('No db values found to cleanup, all is good!'); 70 | $this->logger->logNoActionTaken(); 71 | 72 | return false; 73 | } 74 | 75 | if ($input->isInteractive()) { 76 | $this->displayDbValues($values, $dbTable, $output); 77 | } 78 | 79 | return $this->askForConfirmation($input, $output); 80 | } 81 | 82 | /** 83 | * @param array $deletedPaths 84 | * @param array $skippedPaths 85 | */ 86 | public function showFinalInfo( 87 | array $deletedPaths, 88 | array $skippedPaths, 89 | int $numberOfFilesRemoved, 90 | int $bytesRemoved, 91 | OutputInterface $output 92 | ): void { 93 | if ($skippedPaths !== []) { 94 | $output->writeln(sprintf("Skipped these paths:\n- %s", implode("\n- ", $skippedPaths))); 95 | $output->writeln(''); 96 | } 97 | 98 | if ($deletedPaths !== []) { 99 | $output->writeln(sprintf("Deleted these paths:\n- %s", implode("\n- ", $deletedPaths))); 100 | $output->writeln(''); 101 | 102 | if ($this->fileStatsCalculator->canCalculate()) { 103 | $output->writeln(sprintf( 104 | 'Cleaned up %d files, was able to cleanup %s!', 105 | $numberOfFilesRemoved, 106 | $this->formatBytes($bytesRemoved) 107 | )); 108 | $this->logger->logFinalSummary($numberOfFilesRemoved, $this->formatBytes($bytesRemoved)); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * @param array $deletedValues 115 | */ 116 | public function showFinalDbInfo( 117 | array $deletedValues, 118 | int $numberOfValuesDeleted, 119 | string $dbTable, 120 | OutputInterface $output 121 | ): void { 122 | if ($deletedValues !== []) { 123 | $output->writeln(sprintf( 124 | "- %s\n\nDeleted the above %d values", 125 | implode("\n- ", $deletedValues), 126 | count($deletedValues) 127 | )); 128 | $output->writeln(''); 129 | 130 | $this->logger->logFinalDbSummary($numberOfValuesDeleted, $dbTable); 131 | } 132 | } 133 | 134 | /** 135 | * @param array $paths 136 | */ 137 | private function displayPaths(array $paths, OutputInterface $output): void 138 | { 139 | // MAYBE TODO: if the amount of paths is bigger than some number (100?) 140 | // then try to group the output in something like: "/pub/media/catalog/product/xxx/ (xx items)"" 141 | $output->writeln('We found the following paths to delete:'); 142 | 143 | foreach ($paths as $path) { 144 | $output->writeln(sprintf('- %s', $path)); 145 | } 146 | $output->writeln(''); 147 | } 148 | 149 | /** 150 | * @param array $values 151 | */ 152 | private function displayDbValues(array $values, string $dbTable, OutputInterface $output): void 153 | { 154 | foreach ($values as $value) { 155 | $output->writeln(sprintf('- %s', $value)); 156 | } 157 | 158 | $output->writeln(''); 159 | $output->writeln(sprintf( 160 | 'We found the above %d values to delete in the database table %s:', 161 | count($values), 162 | $dbTable 163 | )); 164 | } 165 | 166 | /** 167 | * @param array $paths 168 | */ 169 | private function calculateAndDisplayStats(array $paths, OutputInterface $output): void 170 | { 171 | if ($this->fileStatsCalculator->canCalculate()) { 172 | $this->fileStatsCalculator->resetStats(); 173 | $this->fileStatsCalculator->calculateStatsForPaths($paths); 174 | 175 | $numberOfFiles = $this->fileStatsCalculator->getNumberOfFiles(); 176 | $totalSizeInBytes = $this->fileStatsCalculator->getTotalSizeInBytes(); 177 | 178 | $output->writeln(sprintf('Total files: %d', $numberOfFiles)); 179 | $output->writeln(sprintf('Filesize: %s', $this->formatBytes($totalSizeInBytes))); 180 | $output->writeln(''); 181 | } 182 | } 183 | 184 | // from: https://stackoverflow.com/a/2510459 185 | private function formatBytes(int $bytes, int $precision = 2): string 186 | { 187 | $units = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB']; 188 | 189 | $bytes = max($bytes, 0); 190 | $pow = floor(($bytes > 0 ? log($bytes) : 0) / log(1024)); 191 | $pow = (int) min($pow, count($units) - 1); 192 | 193 | $bytes /= (1 << (10 * $pow)); 194 | 195 | return round($bytes, $precision) . ' ' . $units[$pow]; 196 | } 197 | 198 | private function askForConfirmation(InputInterface $input, OutputInterface $output): bool 199 | { 200 | $result = false; 201 | 202 | if ($input->isInteractive()) { 203 | $question = new ConfirmationQuestion('Continue with the deletion of these [y/N]? ', false); 204 | $questionHelper = new QuestionHelper(); 205 | 206 | if ((bool) $questionHelper->ask($input, $output, $question)) { 207 | $result = true; 208 | $output->writeln(''); 209 | } 210 | } else { 211 | $result = true; 212 | } 213 | 214 | return $result; 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /Finder/UnusedCacheHashDirectoriesFinder.php: -------------------------------------------------------------------------------- 1 | paramsBuilder = $paramsBuilder; 45 | $this->assetImageFactory = $assetImageFactory; 46 | $this->filesystem = $filesystem; 47 | $this->filesystemFileDriver = $filesystemFileDriver; 48 | $this->viewConfig = $viewConfig; 49 | $this->storeManager = $storeManager; 50 | $this->themeCustomizationConfig = $themeCustomizationConfig; 51 | $this->themeCollection = $themeCollection; 52 | } 53 | 54 | /** 55 | * @return array 56 | */ 57 | public function find(): array 58 | { 59 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); 60 | 61 | $cacheDir = 'catalog/product/cache'; 62 | $allDirectories = []; 63 | if ($mediaDirectory->isExist($cacheDir)) { 64 | $allDirectories = $mediaDirectory->read($cacheDir); 65 | } 66 | $usedDirectories = $this->getUsedCacheHashDirectories($mediaDirectory); 67 | 68 | /** @var array */ 69 | $unusedDirectories = array_diff($allDirectories, $usedDirectories); 70 | 71 | $unusedDirectories = array_map(function (string $directory) use ($mediaDirectory): string { 72 | $absolutePath = $this->filesystemFileDriver->getRealPath($mediaDirectory->getAbsolutePath($directory)); 73 | 74 | if (!is_string($absolutePath)) { 75 | throw new FileSystemException( 76 | __('Can\'t find media directory path: "%1"', $mediaDirectory->getAbsolutePath($directory)) 77 | ); 78 | } 79 | 80 | return $absolutePath; 81 | }, $unusedDirectories); 82 | 83 | return $unusedDirectories; 84 | } 85 | 86 | /** 87 | * @return array 88 | */ 89 | private function getUsedCacheHashDirectories(ReadDirectory $mediaDirectory): array 90 | { 91 | $directories = []; 92 | 93 | $originalImageName = 'baldwin_imagecleanup_test_file.jpg'; 94 | 95 | foreach ($this->getViewImages($this->getThemesInUse()) as $imageParams) { 96 | // Copied first few lines from Magento\MediaStorage\Service\ImageResize::resize 97 | // - this hasn't been changed between Magento 2.3.4 and 2.4.7 98 | // - there were some small changes introduced in 2.4.0, but those won't affect the result over here 99 | unset($imageParams['id']); 100 | $imageAsset = $this->assetImageFactory->create( 101 | [ 102 | 'miscParams' => $imageParams, 103 | 'filePath' => $originalImageName, 104 | ] 105 | ); 106 | $imageAssetPath = $imageAsset->getPath(); 107 | $mediaStorageFilename = $mediaDirectory->getRelativePath($imageAssetPath); 108 | // End Copied code 109 | 110 | if (preg_match( 111 | '#^(catalog/product/cache/[a-f0-9]{32})/' . preg_quote($originalImageName) . '$#', 112 | $mediaStorageFilename, 113 | $matches 114 | ) !== 1) { 115 | throw new LocalizedException( 116 | __('File: "%1" doesn\'t seem like a valid cache directory', $mediaStorageFilename) 117 | ); 118 | } 119 | $directory = $matches[1]; 120 | 121 | $directories[] = $directory; 122 | } 123 | 124 | $directories = array_unique($directories); 125 | 126 | return $directories; 127 | } 128 | 129 | /** 130 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.4 and 2.4.7 131 | * 132 | * @param array $themes 133 | * 134 | * @return array> 135 | */ 136 | private function getViewImages(array $themes): array 137 | { 138 | $viewImages = []; 139 | $stores = $this->storeManager->getStores(true); 140 | /** @var Theme $theme */ 141 | foreach ($themes as $theme) { 142 | $config = $this->viewConfig->getViewConfig( 143 | [ 144 | 'area' => Area::AREA_FRONTEND, 145 | 'themeModel' => $theme, 146 | ] 147 | ); 148 | $images = $config->getMediaEntities('Magento_Catalog', ImageHelper::MEDIA_TYPE_CONFIG_NODE); 149 | foreach ($images as $imageId => $imageData) { 150 | foreach ($stores as $store) { 151 | $data = $this->paramsBuilder->build($imageData, (int) $store->getId()); 152 | $uniqIndex = $this->getUniqueImageIndex($data); 153 | $data['id'] = $imageId; 154 | $viewImages[$uniqIndex] = $data; 155 | } 156 | } 157 | } 158 | 159 | return $viewImages; 160 | } 161 | 162 | /** 163 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.0 and 2.4.7 164 | * 165 | * @param array $imageData 166 | */ 167 | private function getUniqueImageIndex(array $imageData): string 168 | { 169 | ksort($imageData); 170 | unset($imageData['type']); 171 | 172 | // phpcs:ignore Magento2.Security.InsecureFunction 173 | return md5(json_encode($imageData) ?: ''); 174 | } 175 | 176 | /** 177 | * Copied from Magento\MediaStorage\Service\ImageResize - this hasn't been changed between Magento 2.3.0 and 2.4.7 178 | * 179 | * @return array 180 | */ 181 | private function getThemesInUse(): array 182 | { 183 | $themesInUse = []; 184 | /** @var ThemeCollection */ 185 | $registeredThemes = $this->themeCollection->loadRegisteredThemes(); 186 | $storesByThemes = $this->themeCustomizationConfig->getStoresByThemes(); 187 | $keyType = is_int(key($storesByThemes)) ? 'getId' : 'getCode'; 188 | foreach ($registeredThemes as $registeredTheme) { 189 | if (array_key_exists($registeredTheme->$keyType(), $storesByThemes)) { 190 | $themesInUse[] = $registeredTheme; 191 | } 192 | } 193 | 194 | return $themesInUse; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Finder/UnusedFilesFinder.php: -------------------------------------------------------------------------------- 1 | > */ 26 | private $fileMapping = []; 27 | 28 | public function __construct( 29 | ProgressIndicator $progressIndicator, 30 | ResourceConnection $resource, 31 | Filesystem $filesystem, 32 | FileIo $fileIo, 33 | ConfigReader $configReader 34 | ) { 35 | $this->progressIndicator = $progressIndicator; 36 | $this->resource = $resource; 37 | $this->filesystem = $filesystem; 38 | $this->fileIo = $fileIo; 39 | $this->configReader = $configReader; 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | public function find(): array 46 | { 47 | $this->progressIndicator->start('Searching for unused files'); 48 | 49 | $filesInDb = $this->getFilenamesFromDb(); 50 | $filesInDb = $this->extendWithAlternatives($filesInDb); 51 | 52 | $this->progressIndicator->advance(); 53 | 54 | $filesOnDisk = $this->getFilesOnDisk(); 55 | 56 | $unusedFiles = array_diff($filesOnDisk, $filesInDb); 57 | $unusedFiles = $this->getUnusedFilesAbsolutePaths($unusedFiles); 58 | 59 | $this->progressIndicator->stop(); 60 | 61 | return $unusedFiles; 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | private function getFilenamesFromDb(): array 68 | { 69 | // we don't check here if the gallery entries are assigned to an existing entity 70 | // we have the catalog:images:remove-obsolete-db-entries command to get rid of those obsolete entries first 71 | 72 | $connection = $this->resource->getConnection(); 73 | $select = $connection->select() 74 | ->from($this->resource->getTableName(GalleryResourceModel::GALLERY_TABLE)) 75 | ->reset(Select::COLUMNS) 76 | ->columns(new \Zend_Db_Expr('DISTINCT value')) 77 | // we only search for filenames that start with /x/, 78 | // because we will always assume this sort of filepath later when we compare with files on the disk 79 | ->where("value LIKE '/_/%'") 80 | ; 81 | 82 | /** @var array */ 83 | $filenames = $connection->fetchCol($select); 84 | 85 | $filenames = array_map( 86 | function (string $file) { 87 | return ltrim($file, '/'); 88 | }, 89 | $filenames 90 | ); 91 | 92 | return $filenames; 93 | } 94 | 95 | /** 96 | * @return array 97 | */ 98 | private function getFilesOnDisk(): array 99 | { 100 | $filesOnDisk = []; 101 | 102 | $mediaDirectory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); 103 | $mediaDirectoryPath = $mediaDirectory->getAbsolutePath(); 104 | 105 | $productImageDirectories = $this->getProductImageDirectories($mediaDirectory); 106 | $this->progressIndicator->setMax(count($productImageDirectories)); 107 | 108 | foreach ($productImageDirectories as $productImageDir) { 109 | $fileIterator = new \RecursiveIteratorIterator( 110 | new \RecursiveDirectoryIterator( 111 | $mediaDirectory->getAbsolutePath($productImageDir), 112 | \FilesystemIterator::SKIP_DOTS 113 | ) 114 | ); 115 | 116 | /** @var \SplFileInfo $file */ 117 | foreach ($fileIterator as $file) { 118 | if ($file->isFile()) { 119 | $absPath = $file->getRealPath(); 120 | $filenameWithoutExtension = $file->getBasename('.' . $file->getExtension()); 121 | $filename = $this->normalizeFilename($absPath, $mediaDirectoryPath); 122 | if ($filename !== null) { 123 | // keep track of mapping between relative filename and absolute paths, 124 | // because a single relative filename could match multiple absolute paths 125 | if (!array_key_exists($filename, $this->fileMapping)) { 126 | $this->fileMapping[$filename] = []; 127 | } 128 | $this->fileMapping[$filename][] = $absPath; 129 | 130 | $filesOnDisk[] = $filename; 131 | } 132 | } 133 | } 134 | 135 | $this->progressIndicator->advance(); 136 | } 137 | 138 | return array_unique($filesOnDisk); 139 | } 140 | 141 | /** 142 | * @return array 143 | */ 144 | private function getProductImageDirectories(ReadInterface $mediaDirectory): array 145 | { 146 | // find all cached hash subdirectories, 147 | // so the ones starting with a single letter in their directory name 148 | $subHashDirectories = []; 149 | 150 | $cacheDir = 'catalog/product/cache'; 151 | if ($mediaDirectory->isExist($cacheDir)) { 152 | /** @var array */ 153 | $hashDirectories = $mediaDirectory->read($cacheDir); 154 | foreach ($hashDirectories as $hashDir) { 155 | if ($mediaDirectory->isDirectory($hashDir)) { 156 | $subHashDirectories[] = $mediaDirectory->read($hashDir); 157 | } 158 | } 159 | } 160 | 161 | /** @var array */ 162 | $productImageDirectories = array_merge( 163 | $mediaDirectory->read('catalog/product'), 164 | ...$subHashDirectories 165 | ); 166 | 167 | // only allow directories that have one character as lowest path 168 | $productImageDirectories = array_filter( 169 | $productImageDirectories, 170 | function (string $path) use ($mediaDirectory) { 171 | return $mediaDirectory->isDirectory($path) && preg_match('#/.{1}$#', $path) === 1; 172 | } 173 | ); 174 | 175 | return $productImageDirectories; 176 | } 177 | 178 | private function normalizeFilename(string $filename, string $mediaDirectoryPath): ?string 179 | { 180 | // remove media directory path from filename 181 | $regex = '#^' . preg_quote($mediaDirectoryPath) . '#'; 182 | $filename = preg_replace($regex, '', $filename); 183 | 184 | if ($filename !== null) { 185 | // reduce filename path even further so we end up with 186 | // only the part from the directory with one single character 187 | $pathParts = explode('/', $filename); 188 | foreach ($pathParts as $index => $part) { 189 | if (strlen($part) === 1) { 190 | $filename = implode('/', array_splice($pathParts, $index)); 191 | 192 | return $filename; 193 | } 194 | } 195 | } 196 | 197 | return null; 198 | } 199 | 200 | /** 201 | * @param array $files 202 | * 203 | * @return array 204 | */ 205 | private function extendWithAlternatives(array $files): array 206 | { 207 | $alternativeExtensions = $this->configReader->getAlternativeExtensions(); 208 | 209 | $extraFiles = []; 210 | array_walk($files, function (string $filename) use ($alternativeExtensions, &$extraFiles) { 211 | /** @var array */ 212 | $pathInfo = $this->fileIo->getPathInfo($filename); 213 | 214 | $fileWithoutExtension = $pathInfo['dirname'] . '/' . $pathInfo['filename']; 215 | 216 | foreach ($alternativeExtensions as $ext) { 217 | $extraFiles[] = $fileWithoutExtension . '.' . $ext; 218 | $extraFiles[] = $filename . '.' . $ext; 219 | } 220 | }); 221 | 222 | return array_unique(array_merge($files, $extraFiles)); 223 | } 224 | 225 | /** 226 | * @param array $paths 227 | * 228 | * @return array 229 | */ 230 | private function getUnusedFilesAbsolutePaths(array $paths): array 231 | { 232 | $result = []; 233 | 234 | foreach ($paths as $path) { 235 | $result[] = $this->fileMapping[$path]; 236 | } 237 | 238 | return array_merge([], ...$result); 239 | } 240 | } 241 | --------------------------------------------------------------------------------