├── 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://packagist.org/packages/numero2/contao-proper-filenames) [](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 |
--------------------------------------------------------------------------------