├── src ├── Resources │ ├── contao │ │ ├── languages │ │ │ ├── en │ │ │ │ ├── tl_files.xlf │ │ │ │ ├── tl_form_field.xlf │ │ │ │ ├── default.xlf │ │ │ │ └── tl_settings.xlf │ │ │ └── de │ │ │ │ ├── tl_form_field.xlf │ │ │ │ ├── tl_files.xlf │ │ │ │ ├── default.xlf │ │ │ │ └── tl_settings.xlf │ │ └── dca │ │ │ ├── tl_files.php │ │ │ ├── tl_form_field.php │ │ │ └── tl_settings.php │ └── config │ │ ├── commands.yml │ │ └── listener.yml ├── ProperFilenamesBundle.php ├── ContaoManager │ └── Plugin.php ├── DependencyInjection │ └── ProperFilenamesExtension.php ├── EventListener │ ├── DataContainer │ │ ├── SettingsListener.php │ │ └── FilesListener.php │ └── Hooks │ │ └── CheckFilenamesListener.php ├── Util │ └── FilenamesUtil.php └── Command │ └── CleanFilesCommand.php ├── composer.json ├── README.md └── LICENSE /src/Resources/contao/languages/en/tl_files.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Do not check/rename 7 | 8 | 9 | Exclude all files and folders from being checked / renamed. 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/tl_form_field.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dateinamen nicht prüfen 7 | 8 | 9 | Alle hochgeladenen Dateien von der Prüfung auf Sonderzeichen ausschließen. 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/en/tl_form_field.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Do not check/rename uploaded files 7 | 8 | 9 | Exclude all uploaded files from being checked / renamed. 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/tl_files.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dateinamen nicht prüfen 7 | 8 | 9 | Alle Dateien und Unterordner dieses Verzeichnisses von der Prüfung auf Sonderzeichen ausschließen. 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ProperFilenamesBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle; 14 | 15 | use Symfony\Component\HttpKernel\Bundle\Bundle; 16 | 17 | 18 | class ProperFilenamesBundle extends Bundle { 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/Resources/config/commands.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autoconfigure: true 4 | 5 | _instanceof: 6 | Contao\CoreBundle\Framework\FrameworkAwareInterface: 7 | calls: 8 | - [setFramework, ['@contao.framework']] 9 | 10 | 11 | numero2_proper_filenames.command.clean_files: 12 | class: numero2\ProperFilenamesBundle\Command\CleanFilesCommand 13 | arguments: 14 | - '%kernel.project_dir%' 15 | - '%contao.upload_path%' 16 | - '@filesystem' 17 | - '@database_connection' 18 | -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_files.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | $GLOBALS['TL_DCA']['tl_files']['fields']['doNotSanitize'] = [ 14 | 'exclude' => true 15 | , 'inputType' => 'checkbox' 16 | , 'eval' => ['tl_class'=>'w50 cbx'] 17 | , 'sql' => "char(1) NOT NULL default ''" 18 | ]; 19 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/en/default.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | The file „%s” has been renamed to „%s” 7 | 8 | 9 | Configure now]]> 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/default.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Die Datei „%s” wurde umbenannt zu %s” 7 | 8 | 9 | Jetzt konfigurieren]]> 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/config/listener.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autoconfigure: true 4 | public: true 5 | 6 | 7 | numero2_proper_filenames.listener.data_container.files: 8 | class: numero2\ProperFilenamesBundle\EventListener\DataContainer\FilesListener 9 | 10 | numero2_proper_filenames.listener.data_container.settings: 11 | class: numero2\ProperFilenamesBundle\EventListener\DataContainer\SettingsListener 12 | 13 | 14 | numero2_proper_filenames.listener.hooks.check_filenames: 15 | class: numero2\ProperFilenamesBundle\EventListener\Hooks\CheckFilenamesListener 16 | arguments: 17 | - '@request_stack' 18 | - '@router' 19 | - '@contao.routing.scope_matcher' 20 | - '@contao.translation.translator' -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_form_field.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | use Contao\CoreBundle\DataContainer\PaletteManipulator; 14 | 15 | 16 | PaletteManipulator::create() 17 | ->addField('doNotSanitize','',PaletteManipulator::POSITION_PREPEND) 18 | ->applyToSubpalette('storeFile', 'tl_form_field'); 19 | 20 | 21 | $GLOBALS['TL_DCA']['tl_form_field']['fields']['doNotSanitize'] = [ 22 | 'inputType' => 'checkbox' 23 | , 'eval' => ['tl_class'=>'w50 cbx'] 24 | , 'sql' => "char(1) NOT NULL default ''" 25 | ]; 26 | -------------------------------------------------------------------------------- /src/ContaoManager/Plugin.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\ContaoManager; 14 | 15 | use Contao\CoreBundle\ContaoCoreBundle; 16 | use Contao\ManagerPlugin\Bundle\BundlePluginInterface; 17 | use Contao\ManagerPlugin\Bundle\Config\BundleConfig; 18 | use Contao\ManagerPlugin\Bundle\Parser\ParserInterface; 19 | use numero2\ProperFilenamesBundle\ProperFilenamesBundle; 20 | 21 | 22 | class Plugin implements BundlePluginInterface { 23 | 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | public function getBundles( ParserInterface $parser ): array { 29 | 30 | return [ 31 | BundleConfig::create(ProperFilenamesBundle::class) 32 | ->setLoadAfter([ 33 | ContaoCoreBundle::class 34 | ]) 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DependencyInjection/ProperFilenamesExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\DependencyInjection; 14 | 15 | use Symfony\Component\Config\FileLocator; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\Extension\Extension; 18 | use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; 19 | 20 | 21 | class ProperFilenamesExtension extends Extension { 22 | 23 | 24 | /** 25 | * {@inheritdoc} 26 | */ 27 | public function load(array $mergedConfig, ContainerBuilder $container): void { 28 | 29 | $loader = new YamlFileLoader( 30 | $container, 31 | new FileLocator(__DIR__.'/../Resources/config') 32 | ); 33 | 34 | $loader->load('commands.yml'); 35 | $loader->load('listener.yml'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "numero2/contao-proper-filenames", 3 | "type": "contao-module", 4 | "description": "Replaces special characters in filenames right after upload", 5 | "license": "LGPL-3.0-or-later", 6 | "authors": [{ 7 | "name": "numero2 - Agentur für digitales Marketing GbR", 8 | "homepage": "http://www.numero2.de" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=8.0", 13 | "contao/core-bundle": "^4.13 || ^5.0", 14 | "doctrine/dbal": "^3.6 || ^4.3", 15 | "symfony/console": "^5.4 || ^6.4 || ^7.0", 16 | "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0" 17 | }, 18 | "require-dev": { 19 | "contao/manager-plugin": "^2.0" 20 | }, 21 | "conflict": { 22 | "contao/core": "*", 23 | "contao/manager-plugin": "<2.0 || >=3.0" 24 | }, 25 | "extra": { 26 | "contao-manager-plugin": "numero2\\ProperFilenamesBundle\\ContaoManager\\Plugin" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "numero2\\ProperFilenamesBundle\\": "src/" 31 | }, 32 | "classmap": [ 33 | "src/Resources/contao/" 34 | ], 35 | "exclude-from-classmap": [ 36 | "src/Resources/contao/config/", 37 | "src/Resources/contao/dca/", 38 | "src/Resources/contao/languages/", 39 | "src/Resources/contao/templates/" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Resources/contao/dca/tl_settings.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | $GLOBALS['TL_DCA']['tl_settings']['fields']['checkFilenames'] = [ 14 | 'inputType' => 'checkbox' 15 | , 'eval' => ['submitOnChange'=>true, 'tl_class'=>'w50 cbx'] 16 | ]; 17 | 18 | $GLOBALS['TL_DCA']['tl_settings']['fields']['filenameValidCharacters'] = [ 19 | 'inputType' => 'select' 20 | , 'reference' => &$GLOBALS['TL_LANG']['MSC']['validCharacters'] 21 | , 'eval' => ['mandatory'=>true, 'includeBlankOption'=>true, 'decodeEntities'=>true, 'tl_class'=>'w50'] 22 | ]; 23 | 24 | $GLOBALS['TL_DCA']['tl_settings']['fields']['filenameValidCharactersLocale'] = [ 25 | 'inputType' => 'select' 26 | , 'eval' => ['includeBlankOption'=>true, 'chosen'=>true, 'tl_class'=>'w50'] 27 | ]; 28 | 29 | $GLOBALS['TL_DCA']['tl_settings']['fields']['excludeFileExtensions'] = [ 30 | 'inputType' => 'text' 31 | , 'eval' => ['useRawRequestData'=>true, 'tl_class'=>'clr long'] 32 | ]; 33 | 34 | $GLOBALS['TL_DCA']['tl_settings']['fields']['doNotTrimFilenames'] = [ 35 | 'inputType' => 'checkbox' 36 | , 'eval' => ['tl_class'=>'w50 cbx'] 37 | ]; 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Contao Proper Filenames 2 | ======================= 3 | 4 | [![](https://img.shields.io/packagist/v/numero2/contao-proper-filenames.svg?style=flat-square)](https://packagist.org/packages/numero2/contao-proper-filenames) [![](https://img.shields.io/badge/License-LGPL%20v3-blue.svg?style=flat-square)](http://www.gnu.org/licenses/lgpl-3.0) 5 | 6 | About 7 | -- 8 | Sanitizes the filenames of files uploaded via the Contao file manager or Contao form. [Read more](https://www.numero2.de/contao/erweiterungen/proper-filenames.html) 9 | 10 | System requirements 11 | -- 12 | 13 | * [Contao 4.13 or newer](https://github.com/contao/contao) 14 | 15 | 16 | Installation & Configuration 17 | -- 18 | 19 | * Install via Contao Manager or Composer (`composer require numero2/contao-proper-filenames`) 20 | * In the Backend go to `System Settings` and click `Check filenames` under `Upload settings` 21 | * Configure how the filenames should be renamed by choosing an option from `Valid filename characters` 22 | 23 | 24 | Commands 25 | --- 26 | 27 | Recursively sanitize all files and folders in a given directory. 28 | 29 | ```sh 30 | contao-console contao:proper-filenames:sanitize myfolder -r 31 | ``` 32 | The extension only analyzes files that are stored in Contao's DBAFS (tl_files). The DBAFS should be synchronized 33 | before the call - either via the `File manager` in the Backend or with the following console call: 34 | 35 | ```sh 36 | contao-console contao:filesync 37 | ``` 38 | 39 | To get a preview of how everything will be renamed there is also a `--dry-run` flag. 40 | For all available flags and options see the help using `contao-console contao:proper-filenames:sanitize --help`. 41 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/en/tl_settings.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Check filenames 7 | 8 | 9 | Checks filenames after uploading and replaces any forbidden special characters 10 | 11 | 12 | Do not trim filenames 13 | 14 | 15 | By default all renamed files with be trimmed to 32 characters. 16 | 17 | 18 | Valid filename characters 19 | 20 | 21 | Here you can select a custom character set for automatically renamed files. 22 | 23 | 24 | Conversion locale 25 | 26 | 27 | Here you can define a specific locale for the automatically renamed files. For example, selecting German will convert 'ö' to 'oe' instead of 'o'. 28 | 29 | 30 | Exclude files by extensions 31 | 32 | 33 | Here you can define a comma seperated list of file extensions that won't be renamed. 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Resources/contao/languages/de/tl_settings.xlf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dateinamen auf Sonderzeichen prüfen 7 | 8 | 9 | Überprüft Dateinamen nach dem hochladen und ersetzt ggf. unerlaubte Sonderzeichen. 10 | 11 | 12 | Dateinamen nicht kürzen 13 | 14 | 15 | Dateinamen werden standardmässig nach 32 Zeichen abgeschnitten. 16 | 17 | 18 | Gültige Dateinamen-Zeichen 19 | 20 | 21 | Hier kann ein individueller Zeichensatz für automatisch umbenannte Dateien ausgewählt werden. 22 | 23 | 24 | Konvertierungs-Lokalisierung 25 | 26 | 27 | Hier kann eine spezifische Lokalisierung für die automatische umbenannten Dateien ausgewählt werden. Wenn z. B. Deutsch ausgewählt ist, wird 'ö' zu 'oe' statt 'o' umgewandelt. 28 | 29 | 30 | Ignoriere Datei nach Dateiendung 31 | 32 | 33 | Hier kann eine Komma separierte Liste an Dateiendungen angegebn werden, die nicht automatisch umbenannt werden. 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/SettingsListener.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\EventListener\DataContainer; 14 | 15 | use Contao\Config; 16 | use Contao\CoreBundle\DataContainer\PaletteManipulator; 17 | use Contao\CoreBundle\ServiceAnnotation\Callback; 18 | use Contao\CoreBundle\Slug\ValidCharacters; 19 | use Contao\DataContainer; 20 | use Contao\System; 21 | 22 | 23 | class SettingsListener { 24 | 25 | 26 | /** 27 | * Adjust the palettes 28 | * 29 | * @param Contao\DataContainer $dc 30 | * 31 | * @Callback(table="tl_settings", target="config.onload") 32 | */ 33 | public function adjustPalettes( DataContainer $dc ): void { 34 | 35 | PaletteManipulator::create() 36 | ->addField('checkFilenames', 'imageHeight', PaletteManipulator::POSITION_AFTER) 37 | ->applyToPalette('default', 'tl_settings'); 38 | 39 | $GLOBALS['TL_DCA']['tl_settings']['palettes']['__selector__'][] = 'checkFilenames'; 40 | $GLOBALS['TL_DCA']['tl_settings']['subpalettes']['checkFilenames'] = 'filenameValidCharacters,filenameValidCharactersLocale,excludeFileExtensions,doNotTrimFilenames'; 41 | } 42 | 43 | 44 | /** 45 | * Generate options for valid filename characters 46 | * 47 | * @return array 48 | * 49 | * @Callback(table="tl_settings", target="fields.filenameValidCharacters.options") 50 | */ 51 | public function getValidCharacterOptions(): array { 52 | 53 | if( class_exists(ValidCharacters::class) ) { 54 | return System::getContainer()->get('contao.slug.valid_characters')->getOptions(); 55 | } 56 | 57 | return [ 58 | '\pN\p{Ll}' => 'unicodeLowercase', 59 | '\pN\pL' => 'unicode', 60 | '0-9a-z' => 'asciiLowercase', 61 | '0-9a-zA-Z' => 'ascii' 62 | ]; 63 | } 64 | 65 | 66 | /** 67 | * load and set the default value for the "exclude file extensions" setting 68 | * 69 | * @param string $varValue 70 | * @param Contao\DataContainer $dc 71 | * 72 | * @return string 73 | * 74 | * @Callback(table="tl_settings", target="fields.excludeFileExtensions.load") 75 | */ 76 | public static function loadDefaultFileExtenstions( $varValue, DataContainer $dc ): string { 77 | 78 | $configValue = Config::get('excludeFileExtensions'); 79 | 80 | if( $configValue === null ) { 81 | Config::persist('excludeFileExtensions', 'js,css,scss,less,map,html,htm,ttf,ttc,otf,eot,woff,woff2'); 82 | Config::set('excludeFileExtensions', 'js,css,scss,less,map,html,htm,ttf,ttc,otf,eot,woff,woff2'); 83 | $varValue = 'js,css,scss,less,map,html,htm,ttf,ttc,otf,eot,woff,woff2'; 84 | } 85 | 86 | return $varValue; 87 | } 88 | 89 | 90 | /** 91 | * Generate options for languages 92 | * 93 | * @return array 94 | * 95 | * @Callback(table="tl_settings", target="fields.filenameValidCharactersLocale.options") 96 | */ 97 | public static function getLanguages(): array { 98 | 99 | if( System::getContainer()->has('contao.intl.locales') ) { 100 | return System::getContainer()->get('contao.intl.locales')->getLanguages(); 101 | } else { 102 | return System::getLanguages(); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/EventListener/DataContainer/FilesListener.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2025, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\EventListener\DataContainer; 14 | 15 | use Contao\CoreBundle\DataContainer\PaletteManipulator; 16 | use Contao\CoreBundle\ServiceAnnotation\Callback; 17 | use Contao\DataContainer; 18 | use Contao\System; 19 | use Doctrine\DBAL\ArrayParameterType; 20 | use Exception; 21 | use numero2\ProperFilenamesBundle\Util\FilenamesUtil; 22 | 23 | 24 | class FilesListener { 25 | 26 | 27 | /** 28 | * Adjust the palettes 29 | * 30 | * @param Contao\DataContainer $dc 31 | * 32 | * @Callback(table="tl_files", target="config.onload") 33 | */ 34 | public function adjustPalettes( DataContainer $dc ): void { 35 | 36 | PaletteManipulator::create() 37 | ->addField('doNotSanitize','syncExclude',PaletteManipulator::POSITION_AFTER) 38 | ->applyToPalette('default', 'tl_files'); 39 | 40 | if( !$dc->id ) { 41 | return; 42 | } 43 | 44 | $projectDir = System::getContainer()->getParameter('kernel.project_dir'); 45 | 46 | // Remove the doNotSanitize field when editing folders 47 | if( !is_dir($projectDir . '/' . $dc->id) && method_exists(PaletteManipulator::class, 'removeField') ) { 48 | PaletteManipulator::create() 49 | ->removeField('doNotSanitize') 50 | ->applyToPalette('default', $dc->table); 51 | } 52 | } 53 | 54 | 55 | /** 56 | * Check if a parent folder has set do not sanitize 57 | * 58 | * @param string $varValue 59 | * @param Contao\DataContainer $dc 60 | * 61 | * @return string|null 62 | * 63 | * @Callback(table="tl_files", target="fields.doNotSanitize.load") 64 | */ 65 | public static function checkParentFolder( $varValue, $dc ): ?string { 66 | 67 | $aParentFolders = []; 68 | 69 | if( !$varValue && $dc->id ) { 70 | 71 | $aParts = explode('/', $dc->id); 72 | 73 | if( !empty($aParts) ) { 74 | $path = ''; 75 | foreach( $aParts as $folder ) { 76 | $path .= $folder; 77 | $aParentFolders[] = $path; 78 | $path .= '/'; 79 | } 80 | } 81 | } 82 | 83 | if( !empty($aParentFolders) ) { 84 | 85 | $db = System::getContainer()->get('database_connection'); 86 | 87 | $doNotSanitize = (int) $db->fetchOne(" 88 | SELECT count(1) AS count 89 | FROM tl_files 90 | WHERE type='folder' AND doNotSanitize='1' AND path IN (?) 91 | ", [$aParentFolders], [ArrayParameterType::STRING]); 92 | 93 | if( $doNotSanitize > 0 ) { 94 | 95 | try { 96 | 97 | if( $dc->table && $dc->field ) { 98 | $GLOBALS['TL_DCA'][$dc->table]['fields'][$dc->field]['eval']['disabled'] = true; 99 | } 100 | 101 | } catch( Exception $e ) { 102 | } 103 | 104 | return '1'; 105 | } 106 | } 107 | 108 | return $varValue; 109 | } 110 | 111 | 112 | /** 113 | * Sanitizes the given file- or foldername 114 | * 115 | * @param string $strName 116 | * @param Contao\DataContainer|array $dc 117 | * 118 | * @return string 119 | * 120 | * @Callback(table="tl_files", target="fields.name.save") 121 | */ 122 | public function sanitizeFileOrFolderName( $strName, $dc=null ): string { 123 | 124 | return FilenamesUtil::sanitizeFileOrFolderName($strName, $dc); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Util/FilenamesUtil.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\Util; 14 | 15 | use Ausi\SlugGenerator\SlugGenerator; 16 | use Contao\Config; 17 | use Contao\DataContainer; 18 | use Contao\DC_Folder; 19 | use numero2\ProperFilenamesBundle\EventListener\DataContainer\FilesListener; 20 | use stdClass; 21 | 22 | 23 | class FilenamesUtil { 24 | 25 | 26 | /** 27 | * Sanitizes the given file- or foldername 28 | * 29 | * @param string $strName 30 | * @param Contao\DataContainer|array $dc 31 | * 32 | * @return string 33 | */ 34 | public static function sanitizeFileOrFolderName( $strName, $dc=null ): string { 35 | 36 | if( !Config::get('checkFilenames') || !Config::get('filenameValidCharacters') ) { 37 | return $strName; 38 | } 39 | 40 | if( self::skipSanitize($strName, $dc) ) { 41 | return $strName; 42 | } 43 | 44 | // allow slashes when creating new folders 45 | if( $dc instanceof DC_Folder && $dc->table === "tl_files" && $dc->field === "name" ) { 46 | 47 | $aChunks = array_filter(explode(DIRECTORY_SEPARATOR, $strName)); 48 | 49 | // sanitize each chunk 50 | if( $dc->value === '__new__' && count($aChunks) > 1 ) { 51 | 52 | $aNewChunks = []; 53 | 54 | foreach( $aChunks as $chunk ) { 55 | $aNewChunks[] = self::sanitizeFileOrFolderName($chunk, $dc); 56 | } 57 | 58 | $newName = implode(DIRECTORY_SEPARATOR, $aNewChunks); 59 | 60 | return $newName; 61 | } 62 | } 63 | 64 | $newName = $strName; 65 | 66 | // remove forbidden characters 67 | $newName = (new SlugGenerator(self::getSlugOptions()))->generate($newName); 68 | 69 | // replace double underscores 70 | $newName = self::replaceUnderscores($newName); 71 | 72 | // cut name to length 73 | if( !Config::get('doNotTrimFilenames') ) { 74 | $newName = substr( $newName, 0, 32 ); 75 | } 76 | 77 | return $newName; 78 | } 79 | 80 | 81 | /** 82 | * Replaces doubled underscores in the given filename 83 | * 84 | * @param string $strFile 85 | * 86 | * @return string 87 | */ 88 | public static function replaceUnderscores( $strFile ): string { 89 | 90 | $newFilename = str_replace( "__", "_", $strFile ); 91 | 92 | if( $newFilename != $strFile ) { 93 | $newFilename = self::replaceUnderscores($newFilename); 94 | } 95 | 96 | return $newFilename; 97 | } 98 | 99 | 100 | /** 101 | * Check if the given file should not be sanitized 102 | * 103 | * @param string $name 104 | * @param Contao\DataContainer|array|null $dc 105 | * 106 | * @return bool 107 | */ 108 | public static function skipSanitize( $name, $dc ): bool { 109 | 110 | // check if file should be ignored by its extension 111 | // new upload 112 | if( is_array($dc) && !empty($dc['extension']) && in_array($dc['extension'], explode(',', Config::get('excludeFileExtensions'))) ) { 113 | return true; 114 | } 115 | 116 | // rename in BE 117 | if( $dc instanceof DataContainer && !empty($dc->activeRecord->extension) && in_array($dc->activeRecord->extension, explode(',', Config::get('excludeFileExtensions'))) ) { 118 | return true; 119 | } 120 | 121 | // check if a parent folder is set to not sanitize 122 | $objDc = new stdClass(); 123 | 124 | // new upload 125 | if( is_array($dc) && !empty($dc['dirname']) ) { 126 | $objDc->id = $dc['dirname']; 127 | } 128 | // rename in BE 129 | if( $dc instanceof DataContainer && is_string($dc->id) ) { 130 | $objDc->id = $dc->id; 131 | } 132 | 133 | if( $objDc->id ?? null ) { 134 | if( FilesListener::checkParentFolder('', $objDc) ) { 135 | return true; 136 | } 137 | } 138 | 139 | return false; 140 | } 141 | 142 | 143 | /** 144 | * Return the Slug options 145 | * 146 | * @return array The slug options 147 | */ 148 | public static function getSlugOptions(): array { 149 | 150 | $slugOptions = []; 151 | 152 | if( $validChars = Config::get('filenameValidCharacters') ) { 153 | $slugOptions['validChars'] = $validChars; 154 | } 155 | 156 | if( $locale = Config::get('filenameValidCharactersLocale') ) { 157 | $slugOptions['locale'] = $locale; 158 | } 159 | 160 | $slugOptions['validChars'] .= '_'; 161 | 162 | return $slugOptions; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/EventListener/Hooks/CheckFilenamesListener.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\EventListener\Hooks; 14 | 15 | use Contao\Config; 16 | use Contao\CoreBundle\Routing\ScopeMatcher; 17 | use Contao\CoreBundle\ServiceAnnotation\Hook; 18 | use Contao\Files as CoreFiles; 19 | use Contao\FilesModel; 20 | use Contao\Form; 21 | use Contao\Input; 22 | use Contao\Message; 23 | use Contao\System; 24 | use Contao\Widget; 25 | use numero2\ProperFilenamesBundle\Util\FilenamesUtil; 26 | use Symfony\Component\HttpFoundation\RequestStack; 27 | use Symfony\Component\Routing\RouterInterface; 28 | use Symfony\Contracts\Translation\TranslatorInterface; 29 | 30 | 31 | class CheckFilenamesListener { 32 | 33 | 34 | /** 35 | * @var Symfony\Component\HttpFoundation\RequestStack 36 | */ 37 | private RequestStack $requestStack; 38 | 39 | /** 40 | * @var Symfony\Component\Routing\RouterInterface 41 | */ 42 | private RouterInterface $router; 43 | 44 | /** 45 | * @var Contao\CoreBundle\Routing\ScopeMatcher 46 | */ 47 | private ScopeMatcher $scopeMatcher; 48 | 49 | /** 50 | * @var Symfony\Contracts\Translation\TranslatorInterface 51 | */ 52 | private TranslatorInterface $translator; 53 | 54 | 55 | public function __construct( RequestStack $requestStack, RouterInterface $router, ScopeMatcher $scopeMatcher, TranslatorInterface $translator ) { 56 | 57 | $this->requestStack = $requestStack; 58 | $this->router = $router; 59 | $this->scopeMatcher = $scopeMatcher; 60 | $this->translator = $translator; 61 | } 62 | 63 | 64 | /** 65 | * Renames uploaded files (backend) 66 | * 67 | * @param array $arrFiles 68 | * 69 | * @return void 70 | * 71 | * @Hook("postUpload") 72 | */ 73 | public function renameFilesBackend( array &$arrFiles ): void { 74 | 75 | $aRenamed = []; 76 | 77 | if( !Config::get('checkFilenames') ) { 78 | return; 79 | } 80 | 81 | if( !empty($arrFiles) ) { 82 | 83 | $oFiles = null; 84 | $oFiles = CoreFiles::getInstance(); 85 | 86 | foreach( $arrFiles as $i => $file ) { 87 | 88 | $info = pathinfo($file); 89 | 90 | $oldFileName = $info['filename'] . '.' . strtolower($info['extension']); 91 | $newFileName = FilenamesUtil::sanitizeFileOrFolderName($info['filename'], $info) . '.' . strtolower($info['extension']); 92 | 93 | // rename physical file 94 | if( $oldFileName !== $newFileName ) { 95 | 96 | $newFile = $info['dirname'] . '/' . $newFileName; 97 | 98 | $aRenamed[$file] = $newFile; 99 | 100 | $oFiles->rename($file, $newFile); 101 | 102 | // get the database entry created by Contao 103 | $objFile = FilesModel::findByPath($file); 104 | 105 | // check if file already exists under the new name 106 | if( FilesModel::findByPath($newFile) ) { 107 | 108 | // delete old file in database 109 | $objFile->delete(); 110 | 111 | } else { 112 | 113 | $rootDir = System::getContainer()->getParameter('kernel.project_dir'); 114 | 115 | // rename file in database 116 | $objFile->path = $newFile; 117 | $objFile->hash = md5_file($rootDir . '/' . $newFile); 118 | $objFile->name = $newFileName; 119 | 120 | if( $objFile->save() && $this->scopeMatcher->isBackendRequest($this->requestStack->getCurrentRequest()) ) { 121 | 122 | Message::addInfo(sprintf( 123 | $GLOBALS['TL_LANG']['MSC']['proper_filenames_renamed'] 124 | , $oldFileName 125 | , $newFileName 126 | )); 127 | 128 | // write back new filename for use in further hooks 129 | $arrFiles[$i] = $newFile; 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | 138 | /** 139 | * Renames an uploaded file (frontend) 140 | * 141 | * @param Contao\Widget $objWidget 142 | * @param string $formId 143 | * @param array $arrData 144 | * @param Contao\Form $objForm 145 | * 146 | * @return Contao\Widget 147 | * 148 | * @Hook("loadFormField") 149 | */ 150 | public function renameFileUpload( Widget $objWidget, string $formId, array $formData, Form $form ): Widget { 151 | 152 | if( Input::post('FORM_SUBMIT') == $formId ) { 153 | 154 | if( $objWidget->storeFile && !empty($_FILES[$objWidget->name]) && $_FILES[$objWidget->name]['error'] === 0 && !$objWidget->doNotSanitize ) { 155 | 156 | $info = pathinfo($_FILES[$objWidget->name]['name']); 157 | $newFileName = FilenamesUtil::sanitizeFileOrFolderName($info['filename'], $info) . '.' . strtolower($info['extension']); 158 | 159 | $_FILES[$objWidget->name]['name'] = $newFileName; 160 | } 161 | } 162 | 163 | return $objWidget; 164 | } 165 | 166 | 167 | /** 168 | * Checks if the renaming of files is activated but missing settings 169 | * 170 | * @Hook("getSystemMessages") 171 | */ 172 | public function checkMissingSettings(): string { 173 | 174 | if( !Config::get('checkFilenames') || Config::get('checkFilenames') && Config::get('filenameValidCharacters') ) { 175 | return ''; 176 | } 177 | 178 | $msg = sprintf( 179 | $this->translator->trans('ERR.proper_filenames_not_configured', [], 'contao_default') 180 | , $this->router->generate('contao_backend') 181 | ); 182 | return '

'.$msg.'

'; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/Command/CleanFilesCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * @author Michael Bösherz 8 | * @license LGPL 9 | * @copyright Copyright (c) 2024, numero2 - Agentur für digitales Marketing GbR 10 | */ 11 | 12 | 13 | namespace numero2\ProperFilenamesBundle\Command; 14 | 15 | use Contao\Config; 16 | use Contao\CoreBundle\Framework\FrameworkAwareInterface; 17 | use Contao\CoreBundle\Framework\FrameworkAwareTrait; 18 | use Contao\Dbafs; 19 | use Contao\Files; 20 | use Contao\FilesModel; 21 | use Contao\Folder; 22 | use Doctrine\DBAL\Connection; 23 | use numero2\ProperFilenamesBundle\Util\FilenamesUtil; 24 | use Symfony\Component\Console\Attribute\AsCommand; 25 | use Symfony\Component\Console\Command\Command; 26 | use Symfony\Component\Console\Helper\Table; 27 | use Symfony\Component\Console\Input\InputArgument; 28 | use Symfony\Component\Console\Input\InputInterface; 29 | use Symfony\Component\Console\Input\InputOption; 30 | use Symfony\Component\Console\Output\OutputInterface; 31 | use Symfony\Component\Console\Style\SymfonyStyle; 32 | use Symfony\Component\Filesystem\Filesystem; 33 | use Symfony\Component\Filesystem\Path; 34 | 35 | 36 | #[AsCommand( 37 | name: 'contao:proper-filenames:sanitize', 38 | description: 'Sanitizes the file and folder names of the given path inside Contao`s files folder.', 39 | )] 40 | class CleanFilesCommand extends Command implements FrameworkAwareInterface { 41 | 42 | use FrameworkAwareTrait; 43 | 44 | 45 | /** 46 | * @var string 47 | */ 48 | private string $projectDir; 49 | 50 | /** 51 | * @var Symfony\Component\Filesystem\Filesystem 52 | */ 53 | private Filesystem $filesystem; 54 | 55 | /** 56 | * @var Doctrine\DBAL\Connection 57 | */ 58 | private Connection $connection; 59 | 60 | /** 61 | * @var array 62 | */ 63 | private array $settings; 64 | 65 | 66 | public function __construct( string $projectDir, string $uploadPath, Filesystem $filesystem, Connection $connection ) { 67 | 68 | parent::__construct(); 69 | 70 | $this->projectDir = $projectDir; 71 | $this->uploadPath = $uploadPath; 72 | $this->filesystem = $filesystem; 73 | $this->connection = $connection; 74 | 75 | $this->settings = []; 76 | } 77 | 78 | 79 | protected function configure(): void { 80 | 81 | $this 82 | ->addArgument('path', InputArgument::REQUIRED, 'Path for what will be sanitized.') 83 | 84 | ->addOption('folders-only', null, InputOption::VALUE_NONE, 'Only sanitize folders.') 85 | ->addOption('files-only', null, InputOption::VALUE_NONE, 'Only sanitize files.') 86 | ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Sanitize path recursively') 87 | ->addOption('max-depth', 'd', InputOption::VALUE_OPTIONAL, 'Only scan folder up to this depth.', -1) 88 | ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show current and new name of the file or folder without changing anything.') 89 | 90 | ->setHelp( 91 | 'Be careful this action cannot easily be reversed!'. "\n" . 92 | 'Files and folders not synced to DBAFS will not be sanitized!'. "\n" . 93 | 'This command will respect the settings like "excludeFileExtensions", "doNotTrimFilenames", "doNotSanitize", ... for all files and folders it discovers and sanitize them accordingly.' 94 | ) 95 | ; 96 | } 97 | 98 | 99 | protected function execute( InputInterface $input, OutputInterface $output ): int { 100 | 101 | set_time_limit(0); 102 | 103 | $this->framework->initialize(); 104 | $io = new SymfonyStyle($input, $output); 105 | 106 | $path = $input->getArgument('path'); 107 | 108 | $this->loadSettings(); 109 | 110 | if( empty($this->settings['checkFilenames']) || empty($this->settings['filenameValidCharacters']) ) { 111 | 112 | $io->success("Proper filenames is not enabled in settings."); 113 | return Command::SUCCESS; 114 | } 115 | 116 | $optFoldersOnly = $input->getOption('folders-only'); 117 | $optFilesOnly = $input->getOption('files-only'); 118 | $optRecursive = $input->getOption('recursive'); 119 | $optMaxDepth = intval($input->getOption('max-depth')); 120 | $optDryRun = $input->getOption('dry-run'); 121 | 122 | $optNoInteraction = $input->getOption('no-interaction'); 123 | 124 | 125 | $filesPath = Path::join($this->uploadPath, $path); 126 | $basePath = Path::join($this->projectDir, $filesPath); 127 | 128 | if( !$this->filesystem->exists($basePath) ) { 129 | 130 | $io->error('Path not found: "'. $filesPath .'"'); 131 | return Command::FAILURE; 132 | } 133 | 134 | $pathsToRename = $this->findPaths($filesPath, [ 135 | 'folders-only' => $optFoldersOnly 136 | , 'files-only' => $optFilesOnly 137 | , 'recursive' => $optRecursive 138 | , 'max-depth' => $optMaxDepth 139 | , 'level' => 0 140 | ]); 141 | 142 | $countFiles = 0; 143 | $countDirs = 0; 144 | 145 | foreach( $pathsToRename as $key => $data ) { 146 | 147 | if( $data['type'] === 'file' ) { 148 | $countFiles += 1; 149 | } else if( $data['type'] === 'folder' ) { 150 | $countDirs += 1; 151 | } 152 | } 153 | 154 | if( !$optNoInteraction ) { 155 | 156 | $io->section('Sanitizing path "' . $filesPath . '"'); 157 | 158 | $io->writeln(' * Found folders: ' . $countDirs); 159 | $io->writeln(' * Found files: ' . $countFiles); 160 | } 161 | 162 | if( $countFiles === 0 && $countDirs === 0 ) { 163 | 164 | $io->success("Nothing found to sanitize."); 165 | return Command::SUCCESS; 166 | } 167 | 168 | 169 | if( $optDryRun ) { 170 | 171 | $io->section('Result, only display changes'); 172 | 173 | } else { 174 | 175 | if( !$optNoInteraction && !$io->confirm('Execute sanitizing files?') ) { 176 | return Command::SUCCESS; 177 | } 178 | 179 | $io->section('Renaming...'); 180 | } 181 | 182 | $files = Files::getInstance(); 183 | $foldersToDo = []; 184 | 185 | 186 | $countFiles = 0; 187 | $countDirs = 0; 188 | 189 | $table = new Table($output); 190 | $table->setHeaders(['Type', 'Old path', 'New name']); 191 | 192 | foreach( $pathsToRename as $path => $entry ) { 193 | 194 | $fullPath = Path::join($this->projectDir, $path); 195 | $info = pathinfo($fullPath); 196 | 197 | $oldFileName = basename($path); 198 | $newFileName = null; 199 | 200 | if( $entry['type'] === 'file' ) { 201 | 202 | $newFileName = FilenamesUtil::sanitizeFileOrFolderName($info['filename']) . '.' . strtolower($info['extension']); 203 | 204 | } else if( $entry['type'] === 'folder' ) { 205 | 206 | $newFileName = FilenamesUtil::sanitizeFileOrFolderName($info['filename']); 207 | } 208 | 209 | if( $newFileName && $oldFileName !== $newFileName ) { 210 | 211 | if( $optDryRun ) { 212 | 213 | if( $entry['type'] === 'file' ) { 214 | $countFiles += 1; 215 | } else if( $entry['type'] === 'folder' ) { 216 | $countDirs += 1; 217 | } 218 | 219 | $table->addRow([$entry['type'], $path, $newFileName]); 220 | 221 | } else { 222 | 223 | if( $entry['type'] === 'file' ) { 224 | 225 | $newPath = Path::join(dirname($path), $newFileName); 226 | 227 | $countFiles += 1; 228 | $table->addRow([$entry['type'], $path, $newFileName]); 229 | 230 | if( $this->filesystem->exists(Path::join($this->projectDir, $newPath)) || !$files->rename($path, $newPath) ) { 231 | 232 | $table->render(); 233 | 234 | $io->error('Could not rename file: "'. $path .'" to "'. $newFileName .'"'); 235 | return Command::FAILURE; 236 | } 237 | 238 | Dbafs::moveResource($path, $newPath); 239 | 240 | } else if( $entry['type'] === 'folder' ) { 241 | 242 | $foldersToDo[$path] = $newFileName; 243 | 244 | } 245 | } 246 | } 247 | } 248 | 249 | if( !$optDryRun && !empty($foldersToDo) ) { 250 | 251 | foreach( array_reverse($foldersToDo) as $path => $newName ) { 252 | 253 | $newPath = Path::join(dirname($path), $newName); 254 | 255 | $countDirs += 1; 256 | $table->addRow([$entry['type'], $path, $newName]); 257 | 258 | if( $this->filesystem->exists(Path::join($this->projectDir, $newPath)) || !$files->rename($path, $newPath) ) { 259 | 260 | $table->render(); 261 | 262 | $io->error('Could not rename directory: "'. $path .'" to "'. $newName .'"'); 263 | return Command::FAILURE; 264 | } 265 | 266 | Dbafs::moveResource($path, $newPath); 267 | } 268 | } 269 | 270 | if( $countFiles || $countDirs ) { 271 | 272 | $table->render(); 273 | 274 | $io->writeln(' Total renames files: '. $countFiles .' | folders: '. $countDirs); 275 | $io->success("Sanitization done."); 276 | 277 | } else { 278 | 279 | $io->success("Nothing found for sanitize."); 280 | } 281 | 282 | return Command::SUCCESS; 283 | } 284 | 285 | 286 | private function loadSettings(): void { 287 | 288 | $configKeys = ['checkFilenames', 'filenameValidCharacters', 'excludeFileExtensions']; 289 | $settings = []; 290 | 291 | foreach( $configKeys as $key ) { 292 | $settings[$key] = Config::get($key); 293 | } 294 | 295 | $settings['excludeFileExtensions'] = explode(',', $settings['excludeFileExtensions']); 296 | 297 | $this->settings = $settings; 298 | } 299 | 300 | 301 | private function findPaths( string $path, array $flags ): array { 302 | 303 | $fullPath = Path::join($this->projectDir, $path); 304 | 305 | $paths = []; 306 | $entry = $this->generateEntry($path); 307 | 308 | // if empty this means file/folder does not exist 309 | if( empty($entry) ) { 310 | return []; 311 | } 312 | 313 | // check if skip based on parent folders and file settings 314 | if( $this->ignorePath($entry, true) ) { 315 | return []; 316 | } 317 | 318 | // also use folder content 319 | if( $flags['recursive'] ) { 320 | if( $entry['type'] === 'folder' ) { 321 | 322 | $subPaths = $this->findSubPaths($entry, $flags); 323 | 324 | if( count($subPaths) ) { 325 | $paths = array_merge($paths, $subPaths); 326 | } 327 | } 328 | } 329 | 330 | return $paths; 331 | } 332 | 333 | 334 | private function findSubPaths( array $entry, array $flags ): array { 335 | 336 | $path = $entry['model']->path ?? null; 337 | $type = $entry['type'] ?? 'unknown'; 338 | 339 | if( $path === null ) { 340 | return []; 341 | } 342 | 343 | if( $flags['max-depth'] >= 0 && $flags['level'] > $flags['max-depth'] ) { 344 | return []; 345 | } 346 | 347 | if( $this->ignorePath($entry, true) ) { 348 | return []; 349 | } 350 | 351 | $paths = []; 352 | if( (!$flags['files-only'] && !$flags['folders-only']) 353 | || ($flags['files-only'] && $type === 'file') 354 | || ($flags['folders-only'] && $type === 'folder') 355 | ) { 356 | 357 | $paths[$path] = $entry; 358 | } 359 | 360 | // also use folder content 361 | if( $flags['recursive'] ) { 362 | if( $entry['type'] === 'folder' ) { 363 | 364 | $children = Folder::scan( Path::join($this->projectDir, $path)); 365 | 366 | $flags['level'] += 1; 367 | 368 | foreach( $children as $file ) { 369 | 370 | $subPath = Path::join($path, $file); 371 | $subEntry = $this->generateEntry($subPath); 372 | 373 | $subPaths = $this->findSubPaths($subEntry, $flags); 374 | 375 | if( count($subPaths) ) { 376 | $paths = array_merge($paths, $subPaths); 377 | } 378 | } 379 | } 380 | } 381 | 382 | return $paths; 383 | } 384 | 385 | 386 | private function generateEntry( string $path ): array { 387 | 388 | $fullPath = Path::join($this->projectDir, $path); 389 | 390 | if( !$this->filesystem->exists($fullPath) ) { 391 | return []; 392 | } 393 | 394 | $data = []; 395 | 396 | if( is_file($fullPath) ) { 397 | 398 | $data['type'] = 'file'; 399 | 400 | } else if( is_dir($fullPath) ) { 401 | 402 | $data['type'] = 'folder'; 403 | 404 | } else { 405 | 406 | return ['type' => 'unknown']; 407 | } 408 | 409 | if( $path === $this->uploadPath ) { 410 | 411 | $data['model'] = (object)[ 412 | 'path' => $path 413 | ]; 414 | } else { 415 | 416 | $data['model'] = FilesModel::findByPath($path); 417 | } 418 | 419 | 420 | return $data; 421 | } 422 | 423 | 424 | private function ignorePath( array $entry, bool $checkUpwards=false ): bool { 425 | 426 | $path = $entry['model']->path ?? null; 427 | 428 | if( $path === null ) { 429 | return true; 430 | } 431 | 432 | if( $entry['type'] === 'folder' ) { 433 | 434 | if( !array_key_exists($path, $this->settings['skipFolers'] ?? []) ) { 435 | $this->settings['skipFolers'][$path] = $this->ignoreFolder($entry); 436 | } 437 | if( $this->settings['skipFolers'][$path] ?? false ) { 438 | return true; 439 | } 440 | 441 | } else if( $entry['type'] === 'file' ) { 442 | 443 | if( $this->ignoreFile($entry) ) { 444 | return true; 445 | } 446 | } 447 | 448 | if( $checkUpwards ) { 449 | 450 | $parent = dirname($path); 451 | 452 | // end recursion on upload path as no model for the root 453 | if( $path === $this->uploadPath || $parent === $this->uploadPath ) { 454 | return false; 455 | } 456 | 457 | $parentEntry = $this->generateEntry($parent); 458 | 459 | return $this->ignorePath($parentEntry, $checkUpwards); 460 | } 461 | 462 | return false; 463 | } 464 | 465 | 466 | private function ignoreFolder( array $entry ): bool { 467 | 468 | $path = $entry['model']->path ?? null; 469 | 470 | if( $path === null ) { 471 | return true; 472 | } 473 | 474 | if( !empty($entry['model']->doNotSanitize) ) { 475 | return true; 476 | } 477 | 478 | $folder = new Folder($path); 479 | 480 | if( $folder->isUnsynchronized() ) { 481 | return true; 482 | } 483 | 484 | return false; 485 | } 486 | 487 | 488 | private function ignoreFile( array $entry ): bool { 489 | 490 | $extension = $entry['model']->extension ?? null; 491 | 492 | if( $extension && in_array($extension, $this->settings['excludeFileExtensions']) ) { 493 | return true; 494 | } 495 | 496 | return false; 497 | } 498 | } 499 | --------------------------------------------------------------------------------