diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a1e85ac6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore +tests/ export-ignore +.php-cs-fixer.dist.php export-ignore +phpunit.xml.dist export-ignore diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml new file mode 100644 index 00000000..059a2544 --- /dev/null +++ b/.github/workflows/qa.yaml @@ -0,0 +1,93 @@ +name: Quality and Assurance + +on: + push: + pull_request: + +jobs: + qa: + name: Quality + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php-versions: + - 8.2 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP, extensions and composer with shivammathur/setup-php + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, ctype, intl, dom, filter + env: + update: true + + - name: Install Composer dependencies + run: composer install --working-dir=qa/php-cs-fixer + + - name: PHP-CS-Fixer + run: ./qa/php-cs-fixer/vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --verbose --diff + + test: + name: Tests + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php-versions: + - 8.1 + - 8.2 + - 8.3 + - 8.4 + symfony-versions: + - 5.4.* + - 6.4.* + - 7.3.* + dependencies: + - '' + include: + - php-versions: '8.1' + symfony-versions: '5.4.*' + dependencies: 'lowest' + - php-versions: '8.4' + symfony-versions: '7.4.*' + - php-versions: '8.4' + symfony-versions: '8.0.*' + exclude: + - php-versions: '8.1' + symfony-versions: '7.3.*' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP, extensions and composer with shivammathur/setup-php + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, dom, filter, gd, json + tools: flex + env: + update: true + + - name: Install Composer + if: "matrix.dependencies == ''" + run: composer update --no-progress --ansi --prefer-stable + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-versions }} + + - name: Install Lowest Composer dependencies + if: "matrix.dependencies == 'lowest'" + run: composer update --no-progress --ansi --prefer-stable --prefer-lowest + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-versions }} + + - name: Validate composer + run: composer validate --strict --no-check-lock + + - name: Execute unit tests + run: ./vendor/bin/simple-phpunit diff --git a/.gitignore b/.gitignore index ce3aa652..55240f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ +.php-cs-fixer.cache +.phpunit.result.cache phpunit.xml +composer.lock +vendor/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..7de6fa49 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,13 @@ +exclude('Resources') + ->in(__DIR__) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@Symfony' => true, + ]) + ->setFinder($finder) +; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cde3b1ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +## Version 4.1 (08/2022) + + * Add support of the `Cache.SerializerPermissions` for default profile + +## Version 4.0 (04/2022) + + * [BC Break] Drop support for Symfony < 4.4 + * add support for Symfony 6.x + * add support for PHPUnit 10.x + +## Version 3.0 (12/2019) + + * [BC break] Dropped support for PHP 5.x. PHP 7.1 minimum required. + * [BC break] Added type hints for scalar and return type hints where possible. + * [BC Break] The bundle configuration has changed: + ```yaml + # Before + exercise_html_purifier: + default: + Cache.SerializerPath: '%kernel.cache_dir%/htmlpurifier' + # ... + custom: + Core.Encoding: 'ISO-8859-1' + + # After + exercise_html_purifier: + default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' + html_profiles: + default: + # ... + custom: + config: + Core.Encoding: 'ISO-8859-1' + ``` + * Added an `HTMLPurifierConfigFactory` to handle cache and custom definitions. + * Refactored `SerializerCacheWarmer` to preload each profile configuration + +## Version 2.0 (08/2018) + + * Added compatibility for Symfony 5 and Twig 3 + * Updated minimum requirement of Twig to 1.35 and 2.4 to support runtime + * [BC break] Dropped support for Symfony 2. Symfony 3.4 minimum required. + * [BC break] Removed classes parameters. + * [BC break] Removed the form data transformer. + * Added an `HTMLPurifierTextTypeExtension` to add `purify_html` and + `purify_html_profile` options to all `TextType` children. + * Added an `HTMLPurifierListener` to purify submitted form data. + * Added an `HTMLPurifiersRegistryInterface` to lazy load purifiers by profile. + * Added a Twig `HTMLPurifierRuntime` to lazy load purifiers in templates. + * Added a pass to use custom `\HTMLPurifier` classes as custom profiles using + a new `exercise.html_purifier` tag. diff --git a/CacheWarmer/SerializerCacheWarmer.php b/CacheWarmer/SerializerCacheWarmer.php deleted file mode 100644 index 365eb6a4..00000000 --- a/CacheWarmer/SerializerCacheWarmer.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -class SerializerCacheWarmer implements CacheWarmerInterface -{ - private $paths; - - /** - * Constructor. - * - * @param array $paths - */ - public function __construct(array $paths) - { - $this->paths = $paths; - } - - /** - * @see Symfony\Component\HttpKernel\CacheWarmer\WarmableInterface::warmUp() - */ - public function warmUp($cacheDir) - { - foreach ($this->paths as $path) { - if (!is_dir($path)) { - if (false === @mkdir($path, 0777, true)) { - throw new \RuntimeException(sprintf('Unable to create the HTMLPurifier Serializer cache directory "%s".', $path)); - } - } elseif (!is_writable($path)) { - throw new \RuntimeException(sprintf('The HTMLPurifier Serializer cache directory "%s" is not writeable for the current system user.', $path)); - } - } - } - - /** - * @see Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface::isOptional() - */ - public function isOptional() - { - return false; - } -} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php deleted file mode 100644 index dc068210..00000000 --- a/DependencyInjection/Configuration.php +++ /dev/null @@ -1,26 +0,0 @@ -root('exercise_html_purifier'); - - $rootNode - ->useAttributeAsKey('name') - ->prototype('array') - ->useAttributeAsKey('name') - ->prototype('variable') - ->end() - ->end() - ; - - return $treeBuilder; - } -} diff --git a/DependencyInjection/ExerciseHTMLPurifierExtension.php b/DependencyInjection/ExerciseHTMLPurifierExtension.php deleted file mode 100644 index ab208509..00000000 --- a/DependencyInjection/ExerciseHTMLPurifierExtension.php +++ /dev/null @@ -1,116 +0,0 @@ -load('html_purifier.xml'); - - /* Prepend the default configuration. This cannot be defined within the - * Configuration class, since the root node's children are array - * prototypes. - * - * This cache path may be suppressed by either unsetting the "default" - * configuration (relying on canBeUnset() on the prototype node) or - * setting the "Cache.SerializerPath" option to null. - */ - array_unshift($configs, array( - 'default' => array( - 'Cache.SerializerPath' => '%kernel.cache_dir%/htmlpurifier', - ), - )); - - $configs = $this->processConfiguration(new Configuration(), $configs); - $configs = array_map(array($this, 'resolveServices'), $configs); - $paths = array(); - - foreach ($configs as $name => $config) { - $configDefinition = new Definition('%exercise_html_purifier.config.class%'); - - // Handle Symfony >= 2.7 - if (method_exists($configDefinition, 'setFactory')) { - if ('default' === $name) { - $configDefinition - ->setFactory(array('%exercise_html_purifier.config.class%', 'create')) - ->addArgument($config); - } else { - $configDefinition - ->setFactory(array('%exercise_html_purifier.config.class%', 'inherit')) - ->addArgument(new Reference('exercise_html_purifier.config.default')) - ->addMethodCall('loadArray', array($config)); - } - } - // Handle Symfony < 2.7 - else { - $configDefinition->setFactoryClass('%exercise_html_purifier.config.class%'); - - if ('default' === $name) { - $configDefinition - ->setFactoryMethod('create') - ->addArgument($config); - } else { - $configDefinition - ->setFactoryMethod('inherit') - ->addArgument(new Reference('exercise_html_purifier.config.default')) - ->addMethodCall('loadArray', array($config)); - } - } - - $configId = 'exercise_html_purifier.config.' . $name; - $container->setDefinition($configId, $configDefinition); - - $container->setDefinition( - 'exercise_html_purifier.' . $name, - new Definition('%exercise_html_purifier.class%', array(new Reference($configId))) - ); - - if (isset($config['Cache.SerializerPath'])) { - $paths[] = $config['Cache.SerializerPath']; - } - } - - $container->setParameter('exercise_html_purifier.cache_warmer.serializer.paths', array_unique($paths)); - } - - public function getAlias() - { - return 'exercise_html_purifier'; - } - - private function resolveServices($value) - { - if (is_array($value)) { - $value = array_map(array($this, 'resolveServices'), $value); - } else if (is_string($value) && 0 === strpos($value, '@')) { - if (0 === strpos($value, '@?')) { - $value = substr($value, 2); - $invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE; - } else { - $value = substr($value, 1); - $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE; - } - - if ('=' === substr($value, -1)) { - $value = substr($value, 0, -1); - $strict = false; - } else { - $strict = true; - } - - $value = new Reference($value, $invalidBehavior, $strict); - } - - return $value; - } -} diff --git a/ExerciseHTMLPurifierBundle.php b/ExerciseHTMLPurifierBundle.php deleted file mode 100644 index 1cb01644..00000000 --- a/ExerciseHTMLPurifierBundle.php +++ /dev/null @@ -1,9 +0,0 @@ -purifier = $purifier; - } - - /** - * @see Symfony\Component\Form\DataTransformerInterface::transform() - */ - public function transform($value) - { - return $value; - } - - /** - * @see Symfony\Component\Form\DataTransformerInterface::reverseTransform() - */ - public function reverseTransform($value) - { - return $this->purifier->purify($value); - } -} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0fe28c0e --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +https://github.com/Exercise/HTMLPurifierBundle/graphs/contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index e9a55e24..8a24ba2a 100644 --- a/README.md +++ b/README.md @@ -1,216 +1,328 @@ +[![Total Downloads](https://poser.pugx.org/exercise/htmlpurifier-bundle/downloads)](https://packagist.org/packages/exercise/htmlpurifier-bundle) +[![Latest Stable Version](https://poser.pugx.org/exercise/htmlpurifier-bundle/v/stable)](https://packagist.org/packages/exercise/htmlpurifier-bundle) +[![License](https://poser.pugx.org/exercise/htmlpurifier-bundle/license)](https://packagist.org/packages/exercise/htmlpurifier-bundle) +[![Build Status](https://travis-ci.org/Exercise/HTMLPurifierBundle.svg?branch=master)](https://travis-ci.org/Exercise/HTMLPurifierBundle) + # ExerciseHTMLPurifierBundle -This bundle integrates [HTMLPurifier][] into Symfony2. +This bundle integrates [HTMLPurifier][] into Symfony. [HTMLPurifier]: http://htmlpurifier.org/ ## Installation -## Symfony 2.1 and above (using Composer) - -Require the bundle in your composer.json file: +Install the bundle: -``` -{ - "require": { - "exercise/htmlpurifier-bundle": "*", - } -} +```bash +$ composer require exercise/htmlpurifier-bundle ``` -Install the bundle: +## Configuration -``` -$ composer update exercise/htmlpurifier-bundle +If you do not explicitly configure this bundle, an HTMLPurifier service will be +defined as `exercise_html_purifier.default`. This behavior is the same as if you +had specified the following configuration: + +```yaml +# config/packages/exercise_html_purifier.yaml + +exercise_html_purifier: + default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' + # 493 int => ocl "0755" + default_cache_serializer_permissions: 493 ``` -Register the bundle: +The `default` profile is special, it is *always* defined and its configuration +is inherited by all custom profiles. +`exercise_html_purifier.default` is the default service using the base +configuration. -``` php -// app/AppKernel.php +```yaml +# config/packages/exercise_html_purifier.yaml -public function registerBundles() -{ - return array( - new Exercise\HTMLPurifierBundle\ExerciseHTMLPurifierBundle(), - // ... - ); -} +exercise_html_purifier: + default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' + html_profiles: + custom: + config: + Core.Encoding: 'ISO-8859-1' + HTML.Allowed: 'a[href|target],p,br' + Attr.AllowedFrameTargets: '_blank' ``` -## Symfony 2.0.* +In this example, a `exercise_html_purifier.custom` service will also be defined, +which includes cache, encoding, HTML tags and attributes options. Available configuration +options may be found in HTMLPurifier's [configuration documentation][]. + +**Note:** If you define a `default` profile but omit `Cache.SerializerPath`, it +will still default to the path above. You can specify a value of `null` for the +option to suppress the default path. -### Submodule Creation + [configuration documentation]: http://htmlpurifier.org/live/configdoc/plain.html -Add HTMLPurifier and this bundle to your `vendor/` directory: +## Autowiring +By default type hinting `\HtmlPurifier` in your services will autowire +the `exercise_html_purifier.default` service. +To override it and use your own config as default autowired services just add +this configuration: + +```yaml +# config/services.yaml +services: + #... + + exercise_html_purifier.default: '@exercise_html_purifier.custom' ``` -$ git submodule add git://github.com/Exercise/HTMLPurifierBundle.git vendor/bundles/Exercise/HTMLPurifierBundle -$ git submodule add git://github.com/ezyang/htmlpurifier.git vendor/htmlpurifier + +### Using a custom purifier class as default + +If you want to use your own class as default purifier, define the new alias as +below: + +```yaml +# config/services.yaml +services: + # ... + + exercise_html_purifier.default: '@App\Html\CustomHtmlPurifier' ``` -### Class Autoloading +### Argument binding -Register "HTMLPurifier" and the "Exercise" namespace prefix in your project's -`autoload.php`: +The bundle also leverages the alias argument binding for each profile. So the +following config: +```yaml + html_profiles: + blog: + # ... + gallery: + # ... ``` -# app/autoload.php -$loader->registerNamespaces(array( - 'Exercise' => __DIR__ . '/../vendor/bundles', -)); +will register the following binding: -$loader->registerPrefixes(array( - 'HTMLPurifier' => __DIR__ . '/../vendor//htmlpurifier/library', -)); +```php + // default config is bound whichever argument name is used +public function __construct(\HTMLPurifier $purifier) {} +public function __construct(\HTMLPurifier $htmlPurifier) {} +public function __construct(\HTMLPurifier $blogPurifier) {} // blog config +public function __construct(\HTMLPurifier $galleryPurifier) {} // gallery config ``` -### Application Kernel +## Form Type Extension -Add HTMLPurifierBundle to the `registerBundles()` method of your application -kernel: +This bundles provides a form type extension for filtering form fields with +HTMLPurifier. Purification is done early during the PRE_SUBMIT event, which +means that client data will be filtered before being bound to the form. -``` -# app/AppKernel.php +Two options are automatically available in all `TextType` based types: -public function registerBundles() +```php +add('content', TextareaType::class, ['purify_html' => true]) // will use default profile + ->add('sneek_peak', TextType::class, ['purify_html' => true, 'purify_html_profile' => 'sneak_peak']) + // ... + ; + } + + // ... } ``` -## Configuration - -If you do not explicitly configure this bundle, an HTMLPurifier service will be -defined as `exercise_html_purifier.default`. This behavior is the same as if you -had specified the following configuration: +Every type extending `TextType` (i.e: `TextareaType`) inherit these options. +It also means that if you use a type such as [CKEditorType][], you will benefit +from these options without configuring anything. -``` -# app/config.yml + [CKEDitorType]: https://github.com/egeloen/IvoryCKEditorBundle/blob/master/Form/Type/CKEditorType.php#L570 -exercise_html_purifier: - default: - Cache.SerializerPath: '%kernel.cache_dir%/htmlpurifier' -``` +## Twig Filter -The `default` profile is special in that it is used as the configuration for the -`exercise_html_purifier.default` service as well as the base configuration for -other profiles you might define. +This bundles registers a `purify` filter with Twig. Output from this filter is +marked safe for HTML, much like Twig's built-in escapers. The filter may be used +as follows: -``` -# app/config.yml +```twig +{# Filters text's value through the "default" HTMLPurifier service #} +{{ text|purify }} -exercise_html_purifier: - default: - Cache.SerializerPath: '%kernel.cache_dir%/htmlpurifier' - custom: - Core.Encoding: 'ISO-8859-1' +{# Filters text's value through the "custom" HTMLPurifier service #} +{{ text|purify('custom') }} ``` -In this example, a `exercise_html_purifier.custom` service will also be defined, -which includes both the cache and encoding options. Available configuration -options may be found in HTMLPurifier's [configuration documentation][]. +## Purifiers Registry -**Note:** If you define a `default` profile but omit `Cache.SerializerPath`, it -will still default to the path above. You can specify a value of `null` for the -option to suppress the default path. +A `Exercise\HtmlPurifierBundle\HtmlPurifiersRegistry` class is registered by default +as a service. To add your custom instance of purifier, and make it available to +the form type and Twig extensions through its profile name, you can use the tag +`exercise.html_purifier` as follow: - [configuration documentation]: http://htmlpurifier.org/live/configdoc/plain.html +```yaml +# config/services.yaml -## Cache Warming ## +services: + # ... + + App\HtmlPurifier\CustomPurifier: + tags: + - name: exercise.html_purifier + profile: custom +``` -When a path is supplied for HTMLPurifier's `Cache.SerializerPath` configuration -option, an error is raised if the directory is not writable. This bundle defines -a cache warmer service that will collect all `Cache.SerializerPath` options and -ensure those directories exist and are writeable. +Now your purifier can be used when: -## Form Data Transformer +```php +// In a form type +$builder + ->add('content', TextareaType::class, [ + 'purify_html' => true, + 'purify_html_profile' => 'custom', + ]) + // ... +``` -This bundles provides a data transformer class for filtering form fields with -HTMLPurifier. Purification is done during the `reverseTransform()` method, which -means that client data will be filtered during binding to the form. +```twig +{# in a template #} +{{ html_string|purify('custom') }} +``` -The following example demonstrates one possible way to integrate an HTMLPurifier -transformer into a form by way of a custom field type: +## How to Customize a Config Definition -``` php -purifierTransformer = $purifierTransformer; - } + [HTMLPurifier_AttrTypes]: https://github.com/ezyang/htmlpurifier/blob/master/library/HTMLPurifier/AttrTypes.php - public function buildForm(FormBuilderInterface $builder, array $options) - { - $builder->addViewTransformer($this->purifierTransformer); - } +### Whitelist Elements - public function getParent() - { - return 'textarea'; - } +In some case, you might want to set some rules for a specific tag. +This is what the following config is about: - public function setDefaultOptions(OptionsResolverInterface $resolver) - { - $resolver->setDefaults(array( - 'compound' => false, - )); - } +```yaml +# config/packages/exercise_html_purifier.yaml +exercise_html_purifier: + html_profiles: + default: + # ... + elements: + video: + - Block + - 'Optional: (source, Flow) | (Flow, source) | Flow' + - Common # allows a set of common attributes + # The 4th and 5th arguments are optional + - src: URI # list of type rules by attributes + type: Text + width: Length + height: Length + poster: URI + preload: 'Enum#auto,metadata,none' + controls: Bool + source: + - Block + - Flow + - Common + - { src: URI, type: Text } + - [style] # list of forbidden attributes +``` - public function getName() - { - return 'purified_textarea'; - } -} +Would be equivalent to: + +```php +$def = $config->getHTMLDefintion(true); +$def->addElement('video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [ + 'src' => 'URI', + 'type' => 'Text', + 'width' => 'Length', + 'height' => 'Length', + 'poster' => 'URI', + 'preload' => 'Enum#auto,metadata,none', + 'controls' => 'Bool', +]); +$source = $def->addElement('source', 'Block', 'Flow', 'Common', [ + 'src' => 'URI', + 'type' => 'Text', +]); +$source->excludes = ['style' => true]; ``` -Then define both the field type and transformer in the service container: +See [HTMLPurifier documentation][] for more details. + + [HTMLPurifier documentation]: http://htmlpurifier.org/docs/enduser-customize.html + +### Blank Elements -``` xml - - - - - +It might happen that you need a tag clean from any attributes. +Then just add it to the list: - - - - +```yaml +# config/packages/exercise_html_purifier.yaml +exercise_html_purifier: + html_profiles: + default: + # ... + blank_elements: [legend, figcaption] ``` -Additional documentation on data transformers may be found in the -[Symfony2 documentation][]. +## How to Reuse Profiles - [Symfony2 documentation]: http://symfony.com/doc/current/cookbook/form/data_transformers.html +What can really convenient is to reuse some profile definition +to build other custom definitions. -## Twig Filter +```yaml +# config/packages/exercise_html_purifier.yaml +exercise_html_purifier: + html_profiles: + base: + # ... + video: + # ... + all: + parents: [base, video] +``` -This bundles registers a `purify` filter with Twig. Output from this filter is -marked safe for HTML, much like Twig's built-in escapers. The filter may be used -as follows: +In this example the profile named "all" will inherit the "default" profile, +then the two custom ones. The order is important as each profile overrides the +previous, and "all" could define its own rules too. -``` jinja -{# Filters text's value through the "default" HTMLPurifier service #} -{{ text|purify }} +## Contributing -{# Filters text's value through the "custom" HTMLPurifier service #} -{{ text|purify('custom') }} -``` +PRs are welcomed :). Please target the `4.x` branch for bug fixes and `master` +for new features. diff --git a/Resources/config/html_purifier.xml b/Resources/config/html_purifier.xml deleted file mode 100644 index 63f8bc5a..00000000 --- a/Resources/config/html_purifier.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - HTMLPurifier - HTMLPurifier_Config - Exercise\HTMLPurifierBundle\CacheWarmer\SerializerCacheWarmer - Exercise\HTMLPurifierBundle\Twig\HTMLPurifierExtension - - - - - - - %exercise_html_purifier.cache_warmer.serializer.paths% - - - - - - - - - diff --git a/Tests/CacheWarmer/SerializerCacheWarmerTest.php b/Tests/CacheWarmer/SerializerCacheWarmerTest.php deleted file mode 100644 index 6697fbd6..00000000 --- a/Tests/CacheWarmer/SerializerCacheWarmerTest.php +++ /dev/null @@ -1,31 +0,0 @@ -assertFalse($cacheWarmer->isOptional()); - } - - public function testShouldCreatePaths() - { - if (!is_writable(sys_get_temp_dir())) { - $this->markTestSkipped(sprintf('The system temp directory "%s" is not writeable for the current system user.', sys_get_temp_dir())); - } - - $path = sys_get_temp_dir() . '/' . uniqid('htmlpurifierbundle'); - - $cacheWarmer = new SerializerCacheWarmer(array($path)); - $cacheWarmer->warmUp(null); - - $this->assertTrue(is_dir($path)); - $this->assertTrue(is_writeable($path)); - - rmdir($path); - } -} diff --git a/Tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php b/Tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php deleted file mode 100644 index 71faeb08..00000000 --- a/Tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php +++ /dev/null @@ -1,184 +0,0 @@ -container = new ContainerBuilder(); - $this->extension = new ExerciseHTMLPurifierExtension(); - - $this->defaultConfig = array( - 'Cache.SerializerPath' => '%kernel.cache_dir%/htmlpurifier', - ); - } - - public function testShouldLoadDefaultConfiguration() - { - $this->extension->load(array(), $this->container); - - $this->assertDefaultConfigDefinition($this->defaultConfig); - $this->assertCacheWarmerSerializerPaths(array('%kernel.cache_dir%/htmlpurifier')); - } - - public function testShouldAllowOverridingDefaultConfigurationCacheSerializerPath() - { - $config = array( - 'default' => array( - 'AutoFormat.AutoParagraph' => true, - 'Cache.SerializerPath' => null, - ), - ); - - $this->extension->load(array($config), $this->container); - - $this->assertDefaultConfigDefinition($config['default']); - $this->assertCacheWarmerSerializerPaths(array()); - } - - public function testShouldNotDeepMergeOptions() - { - $configs = array( - array('default' => array( - 'Core.HiddenElements' => array('script' => true), - 'Cache.SerializerPath' => null, - )), - array('default' => array( - 'Core.HiddenElements' => array('style' => true), - )), - ); - - $this->extension->load($configs, $this->container); - - $this->assertDefaultConfigDefinition(array( - 'Core.HiddenElements' => array('style' => true), - 'Cache.SerializerPath' => null, - )); - } - - public function testShouldLoadCustomConfiguration() - { - $container = new ContainerBuilder(); - $extension = new ExerciseHTMLPurifierExtension(); - - $config = array( - 'default' => array( - 'AutoFormat.AutoParagraph' => true, - ), - 'simple' => array( - 'Cache.DefinitionImpl' => null, - 'Cache.SerializerPath' => '%kernel.cache_dir%/htmlpurifier-simple', - 'AutoFormat.Linkify' => true, - 'AutoFormat.RemoveEmpty' => true, - 'AutoFormat.RemoveEmpty.RemoveNbsp' => true, - 'HTML.Allowed' => "a[href],strong,em,p,li,ul,ol", - ), - 'advanced' => array( - 'Cache.DefinitionImpl' => null, - ), - ); - - $this->extension->load(array($config), $this->container); - - $this->assertDefaultConfigDefinition(array_replace($this->defaultConfig, $config['default'])); - $this->assertConfigDefinition('simple', $config['simple']); - $this->assertConfigDefinition('advanced', $config['advanced']); - - $this->assertCacheWarmerSerializerPaths(array( - '%kernel.cache_dir%/htmlpurifier', - '%kernel.cache_dir%/htmlpurifier-simple', - )); - } - - public function testShouldResolveServices() - { - $container = new ContainerBuilder; - $extension = new ExerciseHTMLPurifierExtension(); - - $config = array( - 'simple' => array( - 'AutoFormat.Custom' => array('@service_container'), - ), - ); - - $this->extension->load(array($config), $this->container); - - $definition = $this->container->getDefinition('exercise_html_purifier.config.simple'); - $calls = $definition->getMethodCalls(); - - $call = $calls[0]; - $this->assertSame('loadArray', $call[0]); - - $args = $call[1]; - - $this->assertInstanceOf('Symfony\Component\DependencyInjection\Reference', $args[0]['AutoFormat.Custom'][0]); - } - - /** - * Assert that the named config definition extends the default profile and - * loads the given options. - * - * @param string $name - * @param array $config - */ - private function assertConfigDefinition($name, array $config) - { - $this->assertTrue($this->container->hasDefinition('exercise_html_purifier.config.' . $name)); - - $definition = $this->container->getDefinition('exercise_html_purifier.config.' . $name); - $this->assertEquals('%exercise_html_purifier.config.class%', $definition->getClass()); - $this->assertEquals('%exercise_html_purifier.config.class%', $definition->getFactoryClass()); - $this->assertEquals('inherit', $definition->getFactoryMethod()); - - $this->assertEquals(1, count($definition->getArguments())); - $this->assertInstanceOf('Symfony\Component\DependencyInjection\Reference', $definition->getArgument(0)); - $this->assertEquals('exercise_html_purifier.config.default', (string) $definition->getArgument(0)); - - $calls = $definition->getMethodCalls(); - $this->assertEquals(1, count($calls)); - $this->assertEquals('loadArray', $calls[0][0]); - $this->assertEquals(array($config), $calls[0][1]); - } - - /** - * Assert that the default config definition loads the given options. - * - * @param array $config - */ - private function assertDefaultConfigDefinition(array $config) - { - $this->assertTrue($this->container->hasDefinition('exercise_html_purifier.config.default')); - - $definition = $this->container->getDefinition('exercise_html_purifier.config.default'); - $this->assertEquals('%exercise_html_purifier.config.class%', $definition->getClass()); - $this->assertEquals('%exercise_html_purifier.config.class%', $definition->getFactoryClass()); - $this->assertEquals('create', $definition->getFactoryMethod()); - $this->assertEquals(array($config), $definition->getArguments()); - } - - /** - * Assert that the cache warmer serializer paths equal the given array. - * - * @param array $paths - */ - private function assertCacheWarmerSerializerPaths(array $paths) - { - $this->assertEquals($paths, $this->container->getParameter('exercise_html_purifier.cache_warmer.serializer.paths')); - } -} diff --git a/Tests/Form/HTMLPurifierTransformerTest.php b/Tests/Form/HTMLPurifierTransformerTest.php deleted file mode 100644 index b1189166..00000000 --- a/Tests/Form/HTMLPurifierTransformerTest.php +++ /dev/null @@ -1,28 +0,0 @@ -text

'; - - $purifier = $this->getMockBuilder('HTMLPurifier') - ->disableOriginalConstructor() - ->getMock(); - - $purifier->expects($this->once()) - ->method('purify') - ->with($input) - ->will($this->returnValue($purifiedInput)); - - $transformer = new HTMLPurifierTransformer($purifier); - - $this->assertEquals($purifiedInput, $transformer->reverseTransform($input)); - $this->assertEquals($purifiedInput, $transformer->transform($purifiedInput)); - } -} diff --git a/Tests/Twig/HTMLPurifierExtensionTest.php b/Tests/Twig/HTMLPurifierExtensionTest.php deleted file mode 100644 index 7103f0d8..00000000 --- a/Tests/Twig/HTMLPurifierExtensionTest.php +++ /dev/null @@ -1,45 +0,0 @@ -text

'; - - $purifier = $this->getMockBuilder('HTMLPurifier') - ->disableOriginalConstructor() - ->getMock(); - - $purifier->expects($this->once()) - ->method('purify') - ->with($input) - ->will($this->returnValue($purifiedInput)); - - $container = $this->getMock('Symfony\Component\DependencyInjection\ContainerInterface'); - - $container->expects($this->once()) - ->method('get') - ->with('exercise_html_purifier.' . $profile) - ->will($this->returnValue($purifier)); - - $extension = new HTMLPurifierExtension($container); - - $this->assertEquals($purifiedInput, $extension->purify($input, $profile)); - } - - public function providePurifierProfiles() - { - return array( - array('default'), - array('custom'), - ); - } -} diff --git a/Twig/HTMLPurifierExtension.php b/Twig/HTMLPurifierExtension.php deleted file mode 100644 index db404bd1..00000000 --- a/Twig/HTMLPurifierExtension.php +++ /dev/null @@ -1,74 +0,0 @@ -container = $container; - } - - /** - * @see Twig_Extension::getFilters() - */ - public function getFilters() - { - return array( - new \Twig_SimpleFilter('purify', array($this, 'purify'), array('is_safe' => array('html'))), - ); - } - - /** - * Filter the input through an HTMLPurifier service. - * - * @param string $string - * @param string $profile - * @return string - */ - public function purify($string, $profile = 'default') - { - return $this->getHTMLPurifierForProfile($profile)->purify($string); - } - - /** - * Get the HTMLPurifier service corresponding to the given profile. - * - * @param string $profile - * @return \HTMLPurifier - * @throws \RuntimeException - */ - private function getHTMLPurifierForProfile($profile) - { - if (!isset($this->purifiers[$profile])) { - $purifier = $this->container->get('exercise_html_purifier.' . $profile); - - if (!$purifier instanceof \HTMLPurifier) { - throw new \RuntimeException(sprintf('Service "exercise_html_purifier.%s" is not an HTMLPurifier instance.', $profile)); - } - - $this->purifiers[$profile] = $purifier; - } - - return $this->purifiers[$profile]; - } - - /** - * @see Twig_ExtensionInterface::getName() - */ - public function getName() - { - return 'html_purifier'; - } -} diff --git a/composer.json b/composer.json index 16ec5844..b96a1952 100644 --- a/composer.json +++ b/composer.json @@ -1,29 +1,41 @@ { "name": "exercise/htmlpurifier-bundle", "type": "symfony-bundle", - "description": "HTMLPurifier integration for your Symfony2 project", - "keywords": ["htmlpurifier"], + "description": "HTMLPurifier integration for your Symfony project", + "keywords": ["htmlpurifier", "html", "purifier", "symfony"], "homepage": "https://github.com/Exercise/HTMLPurifierBundle", "license": "MIT", "authors": [ - { "name": "contributors", "homepage": "https://github.com/Exercise/HTMLPurifierBundle/contributors" } + { + "name": "contributors", + "homepage": "https://github.com/Exercise/HTMLPurifierBundle/contributors" + } ], "require": { - "php": ">=5.3.2", - "symfony/framework-bundle": "~2.0", - "ezyang/htmlpurifier": "~4.0" + "php": "^8.1", + "ezyang/htmlpurifier": "~4.14", + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "symfony/form": "~2.0", - "twig/twig": "~1.3" + "symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/phpunit-bridge": "^7.3", + "twig/twig": "^2.4.4 || ^3.0" + }, + "autoload": { + "psr-4": { "Exercise\\HTMLPurifierBundle\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "Exercise\\HTMLPurifierBundle\\Tests\\": "tests/" } + }, + "config": { + "sort-packages": true }, "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "5.x-dev" } }, - "autoload": { - "psr-0": { "Exercise\\HTMLPurifierBundle": "" } - }, - "target-dir": "Exercise/HTMLPurifierBundle" + "minimum-stability": "dev" } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b2618a68..60a44c95 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,17 @@ - - ./Tests + ./tests - ./ + ./src ./Resources - ./Tests diff --git a/qa/php-cs-fixer/composer.json b/qa/php-cs-fixer/composer.json new file mode 100644 index 00000000..2fbac5dd --- /dev/null +++ b/qa/php-cs-fixer/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "friendsofphp/php-cs-fixer": "^3.38" + } +} diff --git a/src/CacheWarmer/SerializerCacheWarmer.php b/src/CacheWarmer/SerializerCacheWarmer.php new file mode 100644 index 00000000..932bd9b0 --- /dev/null +++ b/src/CacheWarmer/SerializerCacheWarmer.php @@ -0,0 +1,59 @@ + + * @author Jules Pietri + */ +class SerializerCacheWarmer implements CacheWarmerInterface +{ + private $paths; + private $profiles; + private $registry; + private $filesystem; + + /** + * @param string[] $paths + * @param string[] $profiles + */ + public function __construct(array $paths, array $profiles, HTMLPurifiersRegistryInterface $registry, Filesystem $filesystem) + { + $this->paths = $paths; + $this->profiles = $profiles; + $this->registry = $registry; + $this->filesystem = $filesystem; + } + + public function warmUp($cacheDir, ?string $buildDir = null): array + { + foreach ($this->paths as $path) { + $this->filesystem->remove($path); // clean previous cache + $this->filesystem->mkdir($path); + } + + foreach ($this->profiles as $profile) { + // Will build the configuration + $this->registry->get($profile)->purify("
"); + } + + return []; + } + + public function isOptional(): bool + { + return false; + } +} diff --git a/src/DependencyInjection/Compiler/HTMLPurifierPass.php b/src/DependencyInjection/Compiler/HTMLPurifierPass.php new file mode 100644 index 00000000..1008648a --- /dev/null +++ b/src/DependencyInjection/Compiler/HTMLPurifierPass.php @@ -0,0 +1,53 @@ +hasAlias(HTMLPurifiersRegistryInterface::class)) { + return; + } + + try { + $registry = $container->findDefinition(HTMLPurifiersRegistryInterface::class); + } catch (ServiceNotFoundException $e) { + return; + } + + $purifiers = []; + + foreach ($container->findTaggedServiceIds(self::PURIFIER_TAG) as $id => $tags) { + if (empty($tags[0]['profile'])) { + throw new InvalidConfigurationException(sprintf('Tag "%s" must define a "profile" attribute.', self::PURIFIER_TAG)); + } + + $profile = $tags[0]['profile']; + $purifier = $container->getDefinition($id); + + if (empty($purifier->getArguments())) { + $configId = "exercise_html_purifier.config.$profile"; + $config = $container->hasDefinition($configId) ? $configId : 'exercise_html_purifier.config.default'; + + $purifier->addArgument(new Reference($config)); + } + + $purifiers[$profile] = new Reference($id); + } + + $registry->setArguments([ + ServiceLocatorTagPass::register($container, $purifiers), + ]); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000..ec18707b --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,98 @@ +getRootNode(); + + $rootNode + ->children() + ->scalarNode('default_cache_serializer_path') + ->defaultValue('%kernel.cache_dir%/htmlpurifier') + ->end() + ->scalarNode('default_cache_serializer_permissions') + // (format "%#o" 493) "0755" + ->defaultValue(493) + ->end() + ->arrayNode('html_profiles') + ->useAttributeAsKey('name') + ->normalizeKeys(false) + ->validate() + ->always(function ($profiles) { + foreach ($profiles as $profile => $definition) { + foreach ($definition['parents'] as $parent) { + if (!isset($profiles[$parent])) { + throw new InvalidConfigurationException(sprintf('Invalid parent "%s" is not defined for profile "%s".', $parent, $profile)); + } + } + } + + return $profiles; + }) + ->end() + ->arrayPrototype() + ->children() + ->arrayNode('config') + ->defaultValue([]) + ->info('An array of parameters.') + ->useAttributeAsKey('parameter') + ->normalizeKeys(false) + ->variablePrototype()->end() + ->end() + ->arrayNode('attributes') + ->defaultValue([]) + ->info('Every key is a tag name, with arrays for rules') + ->normalizeKeys(false) + ->useAttributeAsKey('tag_name') + ->arrayPrototype() + ->info('Every key is an attribute name for a rule like "Text"') + ->useAttributeAsKey('attribute_name') + ->normalizeKeys(false) + ->scalarPrototype()->end() + ->end() + ->end() + ->arrayNode('elements') + ->defaultValue([]) + ->info('Every key is a tag name, with an array of four values as definition. The fourth is an optional array of attributes rules.') + ->normalizeKeys(false) + ->useAttributeAsKey('tag_name') + ->info('An array represents a definition, with three required elements: a type ("Inline", "Block", ...), a content type ("Empty", "Optional: #PCDATA", ...), an attributes set ("Core", "Common", ...), a fourth optional may define attributes rules as array, and fifth for forbidden attributes.') + ->arrayPrototype() + ->validate() + ->ifTrue(function ($array) { + $count = count($array); + + return 3 > $count || $count > 5; + }) + ->thenInvalid('An element definition must define three to five elements: a type ("Inline", "Block", ...), a content type ("Empty", "Optional: #PCDATA", ...), an attributes set ("Core", "Common", ...), and a fourth optional may define attributes rules as array, and fifth for forbidden attributes.') + ->end() + ->variablePrototype()->end() + ->end() + ->end() + ->arrayNode('blank_elements') + ->defaultValue([]) + ->info('An array of tag names that should purify everything.') + ->scalarPrototype()->end() + ->end() + ->arrayNode('parents') + ->defaultValue([]) + ->info('An array of config names that should be inherited.') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/ExerciseHTMLPurifierExtension.php b/src/DependencyInjection/ExerciseHTMLPurifierExtension.php new file mode 100644 index 00000000..061a4043 --- /dev/null +++ b/src/DependencyInjection/ExerciseHTMLPurifierExtension.php @@ -0,0 +1,125 @@ +load('services.php'); + + $configs = $this->processConfiguration(new Configuration(), $configs); + + // Set default serializer cache path, while ensuring a default profile is defined + $configs['html_profiles']['default']['config']['Cache.SerializerPath'] = $configs['default_cache_serializer_path']; + + if (!isset($configs['html_profiles']['default']['config']['Cache.SerializerPermissions'])) { + $configs['html_profiles']['default']['config']['Cache.SerializerPermissions'] = $configs['default_cache_serializer_permissions']; + } + + $serializerPaths = []; + + foreach ($configs['html_profiles'] as $name => $definition) { + $configId = "exercise_html_purifier.config.$name"; + $default = null; + $parents = []; // stores inherited configs + + if ('default' !== $name) { + $default = new Reference('exercise_html_purifier.config.default'); + $parentNames = $definition['parents']; + + unset($parentNames['default']); // default is always inherited + foreach ($parentNames as $parentName) { + self::resolveProfileInheritance($parentName, $configs['html_profiles'], $parents); + } + } + + $container->register($configId, \HTMLPurifier_Config::class) + ->setFactory([HTMLPurifierConfigFactory::class, 'create']) + ->setArguments([ + $name, + $definition['config'], + $default, + self::getResolvedConfig('config', $parents), + self::getResolvedConfig('attributes', $parents, $definition), + self::getResolvedConfig('elements', $parents, $definition), + self::getResolvedConfig('blank_elements', $parents, $definition), + ]) + ; + + $id = "exercise_html_purifier.$name"; + $container->register($id, \HTMLPurifier::class) + ->setArguments([new Reference($configId)]) + ->addTag(HTMLPurifierPass::PURIFIER_TAG, ['profile' => $name]) + ; + + if (isset($definition['config']['Cache.SerializerPath'])) { + $serializerPaths[] = $definition['config']['Cache.SerializerPath']; + } + + if ($default) { + $container->registerAliasForArgument($id, \HTMLPurifier::class, "$name.purifier"); + } + } + + $container->register('exercise_html_purifier.purifiers_registry', HTMLPurifiersRegistry::class) + ->setPublic(false) + ; + $container->setAlias(HTMLPurifiersRegistryInterface::class, 'exercise_html_purifier.purifiers_registry') + ->setPublic(false) + ; + $container->setAlias(\HTMLPurifier::class, 'exercise_html_purifier.default') + ->setPublic(false) + ; + $container->getDefinition('exercise_html_purifier.cache_warmer.serializer') + ->setArgument(0, array_unique($serializerPaths)) + ->setArgument(1, array_keys($configs['html_profiles'])) + ; + } + + public function getAlias(): string + { + return 'exercise_html_purifier'; + } + + private static function resolveProfileInheritance(string $parent, array $configs, array &$resolved): void + { + if (isset($resolved[$parent])) { + // Another profile already inherited this config, skip + return; + } + + foreach ($configs[$parent]['parents'] as $grandParent) { + self::resolveProfileInheritance($grandParent, $configs, $resolved); + } + + $resolved[$parent]['config'] = $configs[$parent]['config']; + $resolved[$parent]['attributes'] = $configs[$parent]['attributes']; + $resolved[$parent]['elements'] = $configs[$parent]['elements']; + $resolved[$parent]['blank_elements'] = $configs[$parent]['blank_elements']; + } + + private static function getResolvedConfig(string $parameter, array $parents, ?array $definition = null): array + { + if (null !== $definition) { + return array_filter(array_merge( + array_column($parents, $parameter), + isset($definition[$parameter]) ? $definition[$parameter] : [] + )); + } + + return array_filter(array_column($parents, $parameter)); + } +} diff --git a/src/ExerciseHTMLPurifierBundle.php b/src/ExerciseHTMLPurifierBundle.php new file mode 100644 index 00000000..395a5196 --- /dev/null +++ b/src/ExerciseHTMLPurifierBundle.php @@ -0,0 +1,15 @@ +addCompilerPass(new HTMLPurifierPass()); + } +} diff --git a/src/Form/Listener/HTMLPurifierListener.php b/src/Form/Listener/HTMLPurifierListener.php new file mode 100644 index 00000000..2377acf7 --- /dev/null +++ b/src/Form/Listener/HTMLPurifierListener.php @@ -0,0 +1,50 @@ +registry = $registry; + $this->profile = $profile; + } + + public function purifySubmittedData(FormEvent $event): void + { + if (!is_scalar($data = $event->getData())) { + // Hope there is a view transformer, otherwise an error might happen + return; // because we don't want to handle it here + } + + if (0 === strlen($submittedData = trim((string) $data))) { + if ($submittedData !== $data) { + $event->setData($submittedData); + } + + return; + } + + $event->setData($this->getPurifier()->purify($submittedData)); + } + + public static function getSubscribedEvents(): array + { + return [ + FormEvents::PRE_SUBMIT => ['purifySubmittedData', /* as soon as possible */ 1000000], + ]; + } + + private function getPurifier(): \HTMLPurifier + { + return $this->registry->get($this->profile); + } +} diff --git a/src/Form/TypeExtension/HTMLPurifierTextTypeExtension.php b/src/Form/TypeExtension/HTMLPurifierTextTypeExtension.php new file mode 100644 index 00000000..4af73ec1 --- /dev/null +++ b/src/Form/TypeExtension/HTMLPurifierTextTypeExtension.php @@ -0,0 +1,63 @@ +purifiersRegistry = $registry; + } + + public static function getExtendedTypes(): iterable + { + return [TextType::class]; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefaults([ + 'purify_html' => false, + 'purify_html_profile' => 'default', + ]) + ->setAllowedTypes('purify_html', 'bool') + ->setAllowedTypes('purify_html_profile', ['string', 'null']) + ->setNormalizer('purify_html_profile', function (Options $options, $profile) { + if (!$options['purify_html']) { + return null; + } + + if ($this->purifiersRegistry->has($profile)) { + return $profile; + } + + throw new InvalidOptionsException(sprintf('The profile "%s" is not registered.', $profile)); + }) + ->setNormalizer('trim', function (Options $options, $trim) { + // trim is done in the HTMLPurifierListener + return $options['purify_html'] ? false : $trim; + }) + ; + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + if ($options['purify_html']) { + $builder->addEventSubscriber( + new HTMLPurifierListener($this->purifiersRegistry, $options['purify_html_profile']) + ); + } + } +} diff --git a/src/HTMLPurifierConfigFactory.php b/src/HTMLPurifierConfigFactory.php new file mode 100644 index 00000000..e8e88e4e --- /dev/null +++ b/src/HTMLPurifierConfigFactory.php @@ -0,0 +1,88 @@ + ['src' => 'URI', 'data-type' => Text']] ] + * @param array $elements An array of arrays by element to add or override, arrays must + * hold a type ("Inline, "Block", ...), a content type ("Empty", + * "Optional: #PCDATA", ...), an attributes set ("Core", "Common", + * ...), a fourth optional may define attributes rules as array, and + * a fifth to list forbidden attributes + * @param array $blankElements An array of tag names that should not have any attributes + */ + public static function create( + string $profile, + array $configArray, + ?\HTMLPurifier_Config $defaultConfig = null, + array $parents = [], + array $attributes = [], + array $elements = [], + array $blankElements = [], + ): \HTMLPurifier_Config { + if ($defaultConfig) { + $config = \HTMLPurifier_Config::inherit($defaultConfig); + } else { + $config = \HTMLPurifier_Config::createDefault(); + } + + foreach ($parents as $parent) { + $config->loadArray($parent); + } + + $config->loadArray($configArray); + + // Make the config unique + $config->set('HTML.DefinitionID', $profile); + $config->set('HTML.DefinitionRev', 1); + + $def = $config->maybeGetRawHTMLDefinition(); + + // If the definition is not cached, build it + if ($def && ($attributes || $elements || $blankElements)) { + static::buildHTMLDefinition($def, $attributes, $elements, $blankElements); + } + + return $config; + } + + /** + * Builds a config definition from the given parameters. + * + * This build should never happen on runtime, since purifiers cache should + * be generated during warm up. + */ + public static function buildHTMLDefinition(\HTMLPurifier_HTMLDefinition $def, array $attributes, array $elements, array $blankElements): void + { + foreach ($attributes as $elementName => $rule) { + foreach ($rule as $attributeName => $definition) { + /* @see \HTMLPurifier_AttrTypes */ + $def->addAttribute($elementName, $attributeName, $definition); + } + } + + foreach ($elements as $elementName => $config) { + /* @see \HTMLPurifier_HTMLModule::addElement() */ + $el = $def->addElement($elementName, $config[0], $config[1], $config[2], isset($config[3]) ? $config[3] : []); + + if (isset($config[4])) { + $el->excludes = array_fill_keys($config[4], true); + } + } + + foreach ($blankElements as $blankElement) { + /* @see \HTMLPurifier_HTMLModule::addBlankElement() */ + $def->addBlankElement($blankElement); + } + } +} diff --git a/src/HTMLPurifiersRegistry.php b/src/HTMLPurifiersRegistry.php new file mode 100644 index 00000000..9e4cef1e --- /dev/null +++ b/src/HTMLPurifiersRegistry.php @@ -0,0 +1,25 @@ +purifiersLocator = $purifiersLocator; + } + + public function has(string $profile): bool + { + return $this->purifiersLocator->has($profile); + } + + public function get(string $profile): \HTMLPurifier + { + return $this->purifiersLocator->get($profile); + } +} diff --git a/src/HTMLPurifiersRegistryInterface.php b/src/HTMLPurifiersRegistryInterface.php new file mode 100644 index 00000000..0f484a72 --- /dev/null +++ b/src/HTMLPurifiersRegistryInterface.php @@ -0,0 +1,10 @@ +services() + ->set('exercise_html_purifier.cache_warmer.serializer', SerializerCacheWarmer::class) + ->tag('kernel.cache_warmer') + ->args([ + abstract_arg('cache paths'), + abstract_arg('profiles'), + service(HTMLPurifiersRegistryInterface::class), + service('filesystem'), + ]) + + ->set('exercise_html_purifier.form.text_type_extension', HTMLPurifierTextTypeExtension::class) + ->tag('form.type_extension', ['extended_type' => TextType::class]) + ->args([ + service(HTMLPurifiersRegistryInterface::class), + ]) + + ->set('exercise_html_purifier.twig.extension', HTMLPurifierExtension::class) + ->tag('twig.extension') + + ->set('exercise_html_purifier.twig.runtime', HTMLPurifierRuntime::class) + ->tag('twig.runtime') + ->args([ + service(HTMLPurifiersRegistryInterface::class), + ]); +}; diff --git a/src/Twig/HTMLPurifierExtension.php b/src/Twig/HTMLPurifierExtension.php new file mode 100644 index 00000000..2c922d88 --- /dev/null +++ b/src/Twig/HTMLPurifierExtension.php @@ -0,0 +1,16 @@ + ['html']]), + ]; + } +} diff --git a/src/Twig/HTMLPurifierRuntime.php b/src/Twig/HTMLPurifierRuntime.php new file mode 100644 index 00000000..f1ba5df4 --- /dev/null +++ b/src/Twig/HTMLPurifierRuntime.php @@ -0,0 +1,43 @@ +purifiersRegistry = $registry; + } + + /** + * Filters the input through an \HTMLPurifier service. + * + * @param string|null $string The html string to purify + * @param string $profile A configuration profile name + * + * @return string The purified html string + */ + public function purify(?string $string, string $profile = 'default'): string + { + if (null === $string) { + return ''; + } + + return $this->getHTMLPurifierForProfile($profile)->purify($string); + } + + /** + * Gets the HTMLPurifier service corresponding to the given profile. + * + * @throws \InvalidArgumentException If the profile does not exist + */ + private function getHTMLPurifierForProfile(string $profile): \HTMLPurifier + { + return $this->purifiersRegistry->get($profile); + } +} diff --git a/tests/CacheWarmer/SerializerCacheWarmerTest.php b/tests/CacheWarmer/SerializerCacheWarmerTest.php new file mode 100644 index 00000000..b21ec241 --- /dev/null +++ b/tests/CacheWarmer/SerializerCacheWarmerTest.php @@ -0,0 +1,55 @@ +createMock(HTMLPurifiersRegistryInterface::class), new Filesystem()); + + $this->assertFalse($cacheWarmer->isOptional()); + } + + public function testWarmUpShouldCreatePaths(): void + { + $fs = new Filesystem(); + $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'html_purifier'; + + if ($fs->exists($path)) { + $fs->remove($path); + } + + $this->assertFalse($fs->exists($path)); + + $cacheWarmer = new SerializerCacheWarmer([$path], [], $this->createMock(HTMLPurifiersRegistryInterface::class), $fs); + $cacheWarmer->warmUp(''); + + $this->assertTrue($fs->exists($path)); + + $fs->remove($path); + } + + public function testWarmUpShouldCallPurifyForEachProfile(): void + { + $purifier = $this->createMock(\HTMLPurifier::class); + $purifier->expects($this->exactly(2)) + ->method('purify') + ; + + $registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + $registry->expects($this->exactly(2)) + ->method('get') + ->withConsecutive(['first'], ['second']) + ->willReturn($purifier) + ; + + $cacheWarmer = new SerializerCacheWarmer([], ['first', 'second'], $registry, new Filesystem()); + $cacheWarmer->warmUp(''); + } +} diff --git a/tests/DependencyInjection/Compiler/HTMLPurifierPassTest.php b/tests/DependencyInjection/Compiler/HTMLPurifierPassTest.php new file mode 100644 index 00000000..f5ef7090 --- /dev/null +++ b/tests/DependencyInjection/Compiler/HTMLPurifierPassTest.php @@ -0,0 +1,116 @@ +container = $this->createPartialMock(ContainerBuilder::class, [ + 'hasAlias', + 'findDefinition', + 'findTaggedServiceIds', + 'getDefinition', + ]); + } + + protected function tearDown(): void + { + $this->container = null; + } + + public function testProcessOnlyIfRegistryInterfaceIsDefined(): void + { + $this->container->expects($this->once()) + ->method('hasAlias') + ->with(HTMLPurifiersRegistryInterface::class) + ->willReturn(false) + ; + $this->container->expects($this->never()) + ->method('findDefinition') + ; + + $pass = new HTMLPurifierPass(); + + $pass->process($this->container); + } + + public function testProcess(): void + { + $container = new ContainerBuilder(); + $purifier = $container->register(DummyPurifier::class) + ->addTag('exercise.html_purifier', ['profile' => 'test']) + ; + $registry = $container->register('exercise_html_purifier.purifiers_registry', HTMLPurifiersRegistry::class); + + $container->setAlias(HTMLPurifiersRegistryInterface::class, 'exercise_html_purifier.purifiers_registry'); + + $pass = new HTMLPurifierPass(); + $pass->process($container); + + $this->assertInstanceOf(Reference::class, $config = $purifier->getArgument(0)); + $this->assertSame('exercise_html_purifier.config.default', (string) $config); + $this->assertInstanceOf(Definition::class, $locator = $container->findDefinition($registry->getArgument(0))); + $this->assertArrayHasKey('test', $map = $locator->getArgument(0)); + $this->assertInstanceOf(ServiceClosureArgument::class, $map['test']); + $this->assertSame(DummyPurifier::class, (string) $map['test']->getValues()[0]); + } + + public function testProcessDoNothingIfRegistryIsNotDefined(): void + { + $this->container + ->expects($this->once()) + ->method('hasAlias') + ->with(HTMLPurifiersRegistryInterface::class) + ->willReturn(true) + ; + $this->container + ->expects($this->once()) + ->method('findDefinition') + ->with(HTMLPurifiersRegistryInterface::class) + ->willThrowException($this->createMock(ServiceNotFoundException::class)) + ; + $this->container + ->expects($this->never()) + ->method('findTaggedServiceIds') + ; + + $pass = new HTMLPurifierPass(); + $pass->process($this->container); + } + + public function testProcessFailsIfTaggedServiceMissesProfileName(): void + { + $container = new ContainerBuilder(); + $container->register(DummyPurifier::class) + ->addTag('exercise.html_purifier') + ; + $container->register('exercise_html_purifier.purifiers_registry', HTMLPurifiersRegistry::class); + $container->setAlias(HTMLPurifiersRegistryInterface::class, 'exercise_html_purifier.purifiers_registry'); + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Tag "exercise.html_purifier" must define a "profile" attribute.'); + + $pass = new HTMLPurifierPass(); + $pass->process($container); + } +} + +class DummyPurifier extends \HTMLPurifier +{ +} diff --git a/tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php b/tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php new file mode 100644 index 00000000..fde6d8f8 --- /dev/null +++ b/tests/DependencyInjection/ExerciseHTMLPurifierExtensionTest.php @@ -0,0 +1,516 @@ +container = new ContainerBuilder(); + $this->container->setParameter('kernel.cache_dir', '/tmp'); + $this->extension = new ExerciseHTMLPurifierExtension(); + $this->defaultConfig = [ + 'Cache.SerializerPath' => self::DEFAULT_CACHE_PATH, + 'Cache.SerializerPermissions' => self::DEFAULT_CACHE_PERMISSIONS, + ]; + } + + public function tearDown(): void + { + $this->defaultConfig = null; + $this->extension = null; + $this->container = null; + } + + public function testShouldLoadDefaultConfiguration(): void + { + $this->extension->load([], $this->container); + + $this->assertDefaultConfigDefinition($this->defaultConfig); + $this->assertCacheWarmerSerializerArgs([self::DEFAULT_CACHE_PATH], ['default']); + $this->assertRegistryHasProfiles(['default']); + } + + public function testInvalidParent(): void + { + $config = [ + 'html_profiles' => [ + 'custom' => [ + 'config' => ['AutoFormat.AutoParagraph' => true], + ], + 'custom_2' => [ + 'config' => ['AutoFormat.Linkify' => true], + 'parents' => ['custom', 'unknown'], + ], + ], + ]; + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid parent "unknown" is not defined for profile "custom_2".'); + + $this->extension->load([$config], $this->container); + } + + /** + * @dataProvider provideInvalidElementDefinitions + */ + public function testInvalidElements(array $elementDefinition): void + { + $config = [ + 'html_profiles' => [ + 'default' => [ + 'elements' => ['a' => $elementDefinition], + ], + ], + ]; + + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "exercise_html_purifier.html_profiles.default.elements.a": An element definition must define three to five elements: a type ("Inline", "Block", ...), a content type ("Empty", "Optional: #PCDATA", ...), an attributes set ("Core", "Common", ...), and a fourth optional may define attributes rules as array, and fifth for forbidden attributes.'); + + $this->extension->load([$config], $this->container); + } + + public function provideInvalidElementDefinitions(): iterable + { + yield 'empty array' => [[]]; + yield 'only one argument' => [['']]; + yield 'only two arguments' => [['', '']]; + yield 'too many arguments' => [['', '', '', [], [], 'extra argument']]; + } + + public function testShouldAllowOverridingDefaultConfigurationCacheSerializerPath(): void + { + $config = [ + 'default_cache_serializer_path' => null, + 'html_profiles' => [ + 'default' => [ + 'config' => [ + 'AutoFormat.AutoParagraph' => true, + ], + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertDefaultConfigDefinition(array_merge($config['html_profiles']['default']['config'], [ + 'Cache.SerializerPath' => null, + 'Cache.SerializerPermissions' => 493, + ])); + $this->assertCacheWarmerSerializerArgs([], ['default']); + $this->assertRegistryHasProfiles(['default']); + } + + public function testShouldAllowOverridingDefaultConfigurationCacheSerializerPermissions(): void + { + $config = [ + 'default_cache_serializer_path' => null, + 'default_cache_serializer_permissions' => 511, + 'html_profiles' => [ + 'default' => [ + 'config' => [ + 'AutoFormat.AutoParagraph' => true, + ], + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertDefaultConfigDefinition(array_merge($config['html_profiles']['default']['config'], [ + 'Cache.SerializerPath' => null, + 'Cache.SerializerPermissions' => 511, + ])); + $this->assertCacheWarmerSerializerArgs([], ['default']); + $this->assertRegistryHasProfiles(['default']); + } + + public function testShouldAllowOverridingDefaultConfigurationCacheSerializerPermissionsOnDefaultProfile(): void + { + $config = [ + 'default_cache_serializer_path' => null, + 'html_profiles' => [ + 'default' => [ + 'config' => [ + 'AutoFormat.AutoParagraph' => true, + 'Cache.SerializerPermissions' => 511, + ], + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->assertDefaultConfigDefinition(array_merge($config['html_profiles']['default']['config'], [ + 'Cache.SerializerPath' => null, + 'Cache.SerializerPermissions' => 511, + ])); + $this->assertCacheWarmerSerializerArgs([], ['default']); + $this->assertRegistryHasProfiles(['default']); + } + + public function testShouldNotDeepMergeOptions(): void + { + $configs = [ + ['html_profiles' => [ + 'default' => [ + 'config' => [ + 'Core.HiddenElements' => ['script' => true], + ], + ], + ]], + ['html_profiles' => [ + 'default' => [ + 'config' => [ + 'Core.HiddenElements' => ['style' => true], + ], + ], + ]], + ]; + + $this->extension->load($configs, $this->container); + + $this->assertDefaultConfigDefinition(array_merge([ + 'Core.HiddenElements' => ['style' => true], + ], $this->defaultConfig)); + $this->assertCacheWarmerSerializerArgs([self::DEFAULT_CACHE_PATH], ['default']); + $this->assertRegistryHasProfiles(['default']); + } + + public function testShouldLoadCustomConfiguration(): void + { + $config = [ + 'html_profiles' => [ + 'default' => [ + 'config' => [ + 'AutoFormat.AutoParagraph' => true, + ], + ], + 'simple' => [ + 'config' => [ + 'Cache.DefinitionImpl' => null, + 'Cache.SerializerPath' => '%kernel.cache_dir%/htmlpurifier-simple', + 'AutoFormat.Linkify' => true, + 'AutoFormat.RemoveEmpty' => true, + 'AutoFormat.RemoveEmpty.RemoveNbsp' => true, + 'HTML.Allowed' => 'a[href],strong,em,p,li,ul,ol', + ], + ], + 'advanced' => [ + 'config' => [ + 'Cache.DefinitionImpl' => null, + ], + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $profiles = ['default', 'simple', 'advanced']; + + $this->assertDefaultConfigDefinition(array_merge($config['html_profiles']['default']['config'], $this->defaultConfig)); + $this->assertConfigDefinition('simple', $config['html_profiles']['simple']['config']); + $this->assertConfigDefinition('advanced', $config['html_profiles']['advanced']['config']); + $this->assertCacheWarmerSerializerArgs([ + self::DEFAULT_CACHE_PATH, + self::DEFAULT_CACHE_PATH.'-simple', + ], $profiles); + $this->assertRegistryHasProfiles($profiles); + } + + public function testShouldLoadComplexCustomConfiguration(): void + { + $defaultConfig = [ + 'AutoFormat.AutoParagraph' => true, + ]; + $defaultAttributes = [ + 'a' => ['href' => 'URI'], + 'span' => ['data-link' => 'URI'], + ]; + $defaultBlankElements = [ + 'figcaption', + 'legend', + ]; + $simpleConfig = [ + 'AutoFormat.Linkify' => true, + 'AutoFormat.RemoveEmpty' => true, + 'AutoFormat.RemoveEmpty.RemoveNbsp' => true, + 'HTML.Allowed' => 'a[href],strong,em,p,li,ul,ol', + ]; + $videoElements = [ + 'video' => [ + 'Block', + 'Optional: (source, Flow) | (Flow, source) | Flow', + 'Common', + [ + 'src' => 'URI', + 'type' => 'Text', + 'width' => 'Length', + 'height' => 'Length', + 'poster' => 'URI', + 'preload' => 'Enum#auto,metadata,none', + 'controls' => 'Bool', + ], + ], + ]; + $advancedConfig = [ + 'Core.HiddenElements' => ['script' => true], + ]; + $allParents = ['simple', 'video', 'advanced']; + + $config = [ + 'html_profiles' => [ + 'default' => [ + 'config' => $defaultConfig, + 'attributes' => $defaultAttributes, + 'blank_elements' => $defaultBlankElements, + ], + 'simple' => [ + 'config' => $simpleConfig, + ], + 'video' => [ + 'elements' => $videoElements, + ], + 'advanced' => [ + 'config' => $advancedConfig, + ], + 'all' => [ + 'parents' => $allParents, + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $profiles = ['default', 'simple', 'video', 'advanced', 'all']; + + $this->assertDefaultConfigDefinition( + array_merge($defaultConfig, $this->defaultConfig), + $defaultAttributes, + [], + $defaultBlankElements + ); + $this->assertConfigDefinition('simple', $simpleConfig); + $this->assertConfigDefinition( + 'video', + [/* config */], + [/* parents */], + [/* attributes */], + $videoElements + ); + $this->assertConfigDefinition('advanced', $advancedConfig); + $this->assertConfigDefinition( + 'all', + [/* config */], + [$simpleConfig, /* video config is filtered */ 2 => $advancedConfig], + [/* attributes */], + [/* simple elements are filtered */ 1 => $videoElements], + [/* blank elements */] + ); + $this->assertCacheWarmerSerializerArgs([self::DEFAULT_CACHE_PATH], $profiles); + $this->assertRegistryHasProfiles($profiles); + } + + public function testShouldRegisterAliases(): void + { + if (!method_exists($this->container, 'registerAliasForArgument')) { + $this->markTestSkipped('Alias arguments binding is not available.'); + } + + $config = [ + 'html_profiles' => [ + 'default' => [ + 'config' => [ + 'AutoFormat.AutoParagraph' => true, + ], + ], + 'simple' => [ + 'config' => [ + 'HTML.Allowed' => 'a[href],strong,em,p,li,ul,ol', + ], + ], + 'advanced' => [ + 'config' => [ + 'Core.HiddenElements' => ['script' => true], + ], + ], + ], + ]; + + $this->extension->load([$config], $this->container); + + $this->container->register(ServiceWithDefaultConfig::class) + ->setAutowired(true) + ->setPublic(true) + ; + $this->container->register(ServiceWithDefaultConfig2::class) + ->setAutowired(true) + ->setPublic(true) + ; + $this->container->register(ServiceWithSimpleConfig::class) + ->setAutowired(true) + ->setPublic(true) + ; + $this->container->register(ServiceWithAdvancedConfig::class) + ->setAutowired(true) + ->setPublic(true) + ; + + $this->container->compile(); + + $defaultConfigArgument1 = $this->container->findDefinition(ServiceWithDefaultConfig::class) + ->getArgument(0) + ; + + $this->assertInstanceOf(Reference::class, $defaultConfigArgument1); + $this->assertSame('exercise_html_purifier.default', (string) $defaultConfigArgument1); + + $defaultConfigArgument2 = $this->container->findDefinition(ServiceWithDefaultConfig2::class) + ->getArgument(0) + ; + + $this->assertInstanceOf(Reference::class, $defaultConfigArgument2); + $this->assertSame('exercise_html_purifier.default', (string) $defaultConfigArgument2); + + $simpleConfigArgument = $this->container->findDefinition(ServiceWithSimpleConfig::class) + ->getArgument(0) + ; + + $this->assertInstanceOf(Definition::class, $simpleConfigArgument); + $this->assertSame( + 'simple', + $simpleConfigArgument->getTag(HTMLPurifierPass::PURIFIER_TAG)[0]['profile'] ?? '' + ); + + $advancedConfigArgument = $this->container->findDefinition(ServiceWithAdvancedConfig::class) + ->getArgument(0) + ; + + $this->assertInstanceOf(Definition::class, $advancedConfigArgument); + $this->assertSame( + 'advanced', + $advancedConfigArgument->getTag(HTMLPurifierPass::PURIFIER_TAG)[0]['profile'] ?? '' + ); + } + + /** + * Asserts that the named config definition extends the default profile and + * loads the given options. + */ + private function assertConfigDefinition(string $name, array $config, array $parents = [], array $attributes = [], array $elements = [], array $blankElements = []): void + { + $this->assertTrue($this->container->hasDefinition('exercise_html_purifier.config.'.$name)); + + $definition = $this->container->getDefinition('exercise_html_purifier.config.'.$name); + + $this->assertEquals([new Reference(HTMLPurifierConfigFactory::class), 'create'], $definition->getFactory()); + + $args = $definition->getArguments(); + $defaultConfig = $definition->getArgument(2); + + $this->assertCount(7, $args); + $this->assertSame($name, $definition->getArgument(0)); + $this->assertSame($config, $definition->getArgument(1)); + $this->assertInstanceOf(Reference::class, $defaultConfig); + $this->assertSame('exercise_html_purifier.config.default', (string) $defaultConfig); + $this->assertSame($parents, $definition->getArgument(3)); + $this->assertSame($attributes, $definition->getArgument(4)); + $this->assertSame($elements, $definition->getArgument(5)); + $this->assertSame($blankElements, $definition->getArgument(6)); + } + + /** + * Asserts that the default config definition loads the given options. + */ + private function assertDefaultConfigDefinition(array $config, array $attributes = [], array $elements = [], array $blankElements = []): void + { + $this->assertTrue($this->container->hasDefinition('exercise_html_purifier.config.default')); + + $definition = $this->container->getDefinition('exercise_html_purifier.config.default'); + + $this->assertEquals([new Reference(HTMLPurifierConfigFactory::class), 'create'], $definition->getFactory()); + $this->assertSame(['default', $config, null, [], $attributes, $elements, $blankElements], $definition->getArguments(), 'Default config is invalid.'); + } + + /** + * Asserts that the registry has profiles. + * + * @param string[] $profiles + */ + private function assertRegistryHasProfiles(array $profiles): void + { + foreach ($profiles as $profile) { + $this->assertTrue($this->container->hasDefinition("exercise_html_purifier.$profile")); + $this->assertTrue($this->container->hasDefinition("exercise_html_purifier.config.$profile")); + } + } + + /** + * Assert that the cache warmer serializer paths equal the given array. + */ + private function assertCacheWarmerSerializerArgs(array $paths, array $profiles): void + { + $serializer = $this->container->getDefinition('exercise_html_purifier.cache_warmer.serializer'); + + $this->assertSame($serializer->getArgument(0), $paths); + $this->assertSame($serializer->getArgument(1), $profiles); + $this->assertSame((string) $serializer->getArgument(2), HTMLPurifiersRegistryInterface::class); + $this->assertSame((string) $serializer->getArgument(3), 'filesystem'); + } +} + +class ServiceWithDefaultConfig +{ + public function __construct(\HTMLPurifier $purifier) + { + } +} + +class ServiceWithDefaultConfig2 +{ + public function __construct(\HTMLPurifier $htmlPurifier) + { + } +} + +class ServiceWithSimpleConfig +{ + public function __construct(\HTMLPurifier $simplePurifier) + { + } +} + +class ServiceWithAdvancedConfig +{ + public function __construct(\HTMLPurifier $advancedPurifier) + { + } +} diff --git a/tests/Form/Listener/HTMLPurifierListenerTest.php b/tests/Form/Listener/HTMLPurifierListenerTest.php new file mode 100644 index 00000000..bf5e73c6 --- /dev/null +++ b/tests/Form/Listener/HTMLPurifierListenerTest.php @@ -0,0 +1,108 @@ +text

'; + + $purifier = $this->createMock('HTMLPurifier'); + $purifier + ->expects($this->once()) + ->method('purify') + ->with($input) + ->willReturn($purifiedInput) + ; + + $profile = 'test'; + $registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + $registry + ->expects($this->once()) + ->method('get') + ->with($profile) + ->willReturn($purifier) + ; + + $listener = new HTMLPurifierListener($registry, $profile); + + $event = $this->getFormEvent($input); + + $listener->purifySubmittedData($event); + + $this->assertSame($purifiedInput, $event->getData()); + } + + public function testPurifyTrimEmptyValues(): void + { + $input = ' '; + $trimmedInput = ''; + + $purifier = $this->createMock('HTMLPurifier'); + $purifier + ->expects($this->never()) + ->method('purify') + ; + + $registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + $registry + ->expects($this->never()) + ->method('get') + ; + + $listener = new HTMLPurifierListener($registry, 'test'); + + $event = $this->getFormEvent($input); + + $listener->purifySubmittedData($event); + + $this->assertSame($trimmedInput, $event->getData()); + } + + /** + * @dataProvider provideInvalidInput + */ + public function testPurifyDoNothingForEmptyOrNonScalarData($input): void + { + $registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + $registry + ->expects($this->never()) + ->method('get') + ; + + $listener = new HTMLPurifierListener($registry, 'test'); + + $event = $this->createMock(FormEvent::class); + $event + ->expects($this->once()) + ->method('getData') + ->willReturn($input) + ; + $event + ->expects($this->never()) + ->method('setData') + ; + + $listener->purifySubmittedData($event); + } + + public function provideInvalidInput(): iterable + { + yield ['']; + yield [[]]; + yield [new \stdClass()]; + } + + private function getFormEvent($data): FormEvent + { + return new FormEvent($this->createMock(FormInterface::class), $data); + } +} diff --git a/tests/Form/TypeExtension/HTMLPurifierTextTypeExtensionTest.php b/tests/Form/TypeExtension/HTMLPurifierTextTypeExtensionTest.php new file mode 100644 index 00000000..84682181 --- /dev/null +++ b/tests/Form/TypeExtension/HTMLPurifierTextTypeExtensionTest.php @@ -0,0 +1,130 @@ +registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + + parent::setUp(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->registry = null; + } + + protected function getTypeExtensions(): array + { + return [ + new HTMLPurifierTextTypeExtension($this->registry), + ]; + } + + public function testDefaultOptions(): void + { + $this->registry + ->expects($this->never()) + ->method('has') + ; + $this->registry + ->expects($this->never()) + ->method('get') + ; + + $form = $this->factory->create(TextType::class); + $options = $form->getConfig()->getOptions(); + + $this->assertFalse($options['purify_html']); + $this->assertNull($options['purify_html_profile']); + $this->assertTrue($options['trim']); + $this->assertFalse($this->hasPurifierListener($form)); + } + + public function testPurifyOptionsNeedDefaultProfile(): void + { + $this->registry + ->expects($this->once()) + ->method('has') + ->with('default') + ->willReturn(false) + ; + $this->registry + ->expects($this->never()) + ->method('get') + ; + + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The profile "default" is not registered.'); + + $this->factory->create(TextType::class, null, ['purify_html' => true]); + } + + public function testDefaultOptionsWhenPurifyIsTrue(): void + { + $this->registry + ->expects($this->once()) + ->method('has') + ->with('default') + ->willReturn(true) + ; + + $form = $this->factory->create(TextType::class, null, ['purify_html' => true]); + $options = $form->getConfig()->getOptions(); + + $this->assertTrue($options['purify_html']); + $this->assertSame('default', $options['purify_html_profile']); + $this->assertFalse($options['trim']); + $this->assertTrue($this->hasPurifierListener($form)); + } + + public function testInvalidProfile(): void + { + $this->registry + ->expects($this->once()) + ->method('has') + ->with('test') + ->willReturn(false) + ; + $this->registry + ->expects($this->never()) + ->method('get') + ; + + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The profile "test" is not registered.'); + + $this->factory->create(TextType::class, null, [ + 'purify_html' => true, + 'purify_html_profile' => 'test', + ]); + } + + private function hasPurifierListener(FormInterface $form): bool + { + foreach ($form->getConfig()->getEventDispatcher()->getListeners(FormEvents::PRE_SUBMIT) as $listener) { + if ($listener[0] instanceof HTMLPurifierListener) { + return true; + } + } + + return false; + } +} diff --git a/tests/HTMLPurifierConfigFactoryTest.php b/tests/HTMLPurifierConfigFactoryTest.php new file mode 100644 index 00000000..ff780bfe --- /dev/null +++ b/tests/HTMLPurifierConfigFactoryTest.php @@ -0,0 +1,69 @@ +mkdir(self::$cacheDir); + } + + public static function tearDownAfterClass(): void + { + (new Filesystem())->remove(self::$cacheDir); + } + + public function testCreateUseDoesNotBuildDefinitionByDefault(): void + { + TestHTMLPurifierConfigFactory::create('default', []); + + $this->assertSame(0, TestHTMLPurifierConfigFactory::$calledBuild); + } + + public function testCreateUseSerializedCache(): void + { + $configArgs = [ + 'test', /* profile */ + [/* config array */ + 'Cache.SerializerPath' => self::$cacheDir, + 'HTML.Nofollow' => true, + ], + null, /* default config */ + [], /* parents */ + ['a' => ['href' => 'URI']], /* attributes */ + ]; + + (new \HTMLPurifier( + TestHTMLPurifierConfigFactory::create(...$configArgs) + ))->purify('
test
'); + + TestHTMLPurifierConfigFactory::create(...$configArgs); + + $this->assertSame(1, TestHTMLPurifierConfigFactory::$calledBuild); + } +} + +class TestHTMLPurifierConfigFactory extends HTMLPurifierConfigFactory +{ + /** @var int */ + public static $calledBuild = 0; + + public static function buildHTMLDefinition( + \HTMLPurifier_HTMLDefinition $def, + array $attributes, + array $elements, + array $blankElements, + ): void { + ++self::$calledBuild; + parent::buildHTMLDefinition($def, $attributes, $elements, $blankElements); + } +} diff --git a/tests/HTMLPurifiersRegistryTest.php b/tests/HTMLPurifiersRegistryTest.php new file mode 100644 index 00000000..5dcfacea --- /dev/null +++ b/tests/HTMLPurifiersRegistryTest.php @@ -0,0 +1,78 @@ +locator = $this->createMock(ContainerInterface::class); + $this->registry = new HTMLPurifiersRegistry($this->locator); + } + + protected function tearDown(): void + { + $this->registry = null; + $this->locator = null; + } + + public function provideProfiles(): iterable + { + yield ['default']; + yield ['test']; + } + + /** + * @dataProvider provideProfiles + */ + public function testHas(string $profile): void + { + $this->locator->expects($this->once()) + ->method('has') + ->with($profile) + ->willReturn(true) + ; + + $this->assertTrue($this->registry->has($profile)); + } + + /** + * @dataProvider provideProfiles + */ + public function testHasNot(string $profile): void + { + $this->locator->expects($this->once()) + ->method('has') + ->with($profile) + ->willReturn(false) + ; + + $this->assertFalse($this->registry->has($profile)); + } + + /** + * @dataProvider provideProfiles + */ + public function testGet(string $profile): void + { + $purifier = $this->createMock(\HTMLPurifier::class); + + $this->locator->expects($this->once()) + ->method('get') + ->with($profile) + ->willReturn($purifier) + ; + + $this->assertSame($purifier, $this->registry->get($profile)); + } +} diff --git a/tests/Twig/HTMLPurifierRuntimeTest.php b/tests/Twig/HTMLPurifierRuntimeTest.php new file mode 100644 index 00000000..21594ddb --- /dev/null +++ b/tests/Twig/HTMLPurifierRuntimeTest.php @@ -0,0 +1,46 @@ +text

'; + + $purifier = $this->getMockBuilder('HTMLPurifier') + ->disableOriginalConstructor() + ->getMock(); + + $purifier->expects($this->once()) + ->method('purify') + ->with($input) + ->will($this->returnValue($purifiedInput)); + + $registry = $this->createMock(HTMLPurifiersRegistryInterface::class); + + $registry->expects($this->once()) + ->method('get') + ->with($profile) + ->will($this->returnValue($purifier)) + ; + + $extension = new HTMLPurifierRuntime($registry); + + $this->assertEquals($purifiedInput, $extension->purify($input, $profile)); + } + + public function providePurifierProfiles(): iterable + { + yield ['default']; + yield ['custom']; + } +}