├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── qa └── php-cs-fixer │ └── composer.json └── src ├── CacheWarmer └── SerializerCacheWarmer.php ├── DependencyInjection ├── Compiler │ └── HTMLPurifierPass.php ├── Configuration.php └── ExerciseHTMLPurifierExtension.php ├── ExerciseHTMLPurifierBundle.php ├── Form ├── Listener │ └── HTMLPurifierListener.php └── TypeExtension │ └── HTMLPurifierTextTypeExtension.php ├── HTMLPurifierConfigFactory.php ├── HTMLPurifiersRegistry.php ├── HTMLPurifiersRegistryInterface.php ├── Resources └── config │ └── html_purifier.xml └── Twig ├── HTMLPurifierExtension.php └── HTMLPurifierRuntime.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 4.1 (08/2022) 2 | 3 | * Add support of the `Cache.SerializerPermissions` for default profile 4 | 5 | ## Version 4.0 (04/2022) 6 | 7 | * [BC Break] Drop support for Symfony < 4.4 8 | * add support for Symfony 6.x 9 | * add support for PHPUnit 10.x 10 | 11 | ## Version 3.0 (12/2019) 12 | 13 | * [BC break] Dropped support for PHP 5.x. PHP 7.1 minimum required. 14 | * [BC break] Added type hints for scalar and return type hints where possible. 15 | * [BC Break] The bundle configuration has changed: 16 | ```yaml 17 | # Before 18 | exercise_html_purifier: 19 | default: 20 | Cache.SerializerPath: '%kernel.cache_dir%/htmlpurifier' 21 | # ... 22 | custom: 23 | Core.Encoding: 'ISO-8859-1' 24 | 25 | # After 26 | exercise_html_purifier: 27 | default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' 28 | html_profiles: 29 | default: 30 | # ... 31 | custom: 32 | config: 33 | Core.Encoding: 'ISO-8859-1' 34 | ``` 35 | * Added an `HTMLPurifierConfigFactory` to handle cache and custom definitions. 36 | * Refactored `SerializerCacheWarmer` to preload each profile configuration 37 | 38 | ## Version 2.0 (08/2018) 39 | 40 | * Added compatibility for Symfony 5 and Twig 3 41 | * Updated minimum requirement of Twig to 1.35 and 2.4 to support runtime 42 | * [BC break] Dropped support for Symfony 2. Symfony 3.4 minimum required. 43 | * [BC break] Removed classes parameters. 44 | * [BC break] Removed the form data transformer. 45 | * Added an `HTMLPurifierTextTypeExtension` to add `purify_html` and 46 | `purify_html_profile` options to all `TextType` children. 47 | * Added an `HTMLPurifierListener` to purify submitted form data. 48 | * Added an `HTMLPurifiersRegistryInterface` to lazy load purifiers by profile. 49 | * Added a Twig `HTMLPurifierRuntime` to lazy load purifiers in templates. 50 | * Added a pass to use custom `\HTMLPurifier` classes as custom profiles using 51 | a new `exercise.html_purifier` tag. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | https://github.com/Exercise/HTMLPurifierBundle/graphs/contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Total Downloads](https://poser.pugx.org/exercise/htmlpurifier-bundle/downloads)](https://packagist.org/packages/exercise/htmlpurifier-bundle) 2 | [![Latest Stable Version](https://poser.pugx.org/exercise/htmlpurifier-bundle/v/stable)](https://packagist.org/packages/exercise/htmlpurifier-bundle) 3 | [![License](https://poser.pugx.org/exercise/htmlpurifier-bundle/license)](https://packagist.org/packages/exercise/htmlpurifier-bundle) 4 | [![Build Status](https://travis-ci.org/Exercise/HTMLPurifierBundle.svg?branch=master)](https://travis-ci.org/Exercise/HTMLPurifierBundle) 5 | 6 | # ExerciseHTMLPurifierBundle 7 | 8 | This bundle integrates [HTMLPurifier][] into Symfony. 9 | 10 | [HTMLPurifier]: http://htmlpurifier.org/ 11 | 12 | ## Installation 13 | 14 | Install the bundle: 15 | 16 | ```bash 17 | $ composer require exercise/htmlpurifier-bundle 18 | ``` 19 | 20 | ## Configuration 21 | 22 | If you do not explicitly configure this bundle, an HTMLPurifier service will be 23 | defined as `exercise_html_purifier.default`. This behavior is the same as if you 24 | had specified the following configuration: 25 | 26 | ```yaml 27 | # config/packages/exercise_html_purifier.yaml 28 | 29 | exercise_html_purifier: 30 | default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' 31 | # 493 int => ocl "0755" 32 | default_cache_serializer_permissions: 493 33 | ``` 34 | 35 | The `default` profile is special, it is *always* defined and its configuration 36 | is inherited by all custom profiles. 37 | `exercise_html_purifier.default` is the default service using the base 38 | configuration. 39 | 40 | ```yaml 41 | # config/packages/exercise_html_purifier.yaml 42 | 43 | exercise_html_purifier: 44 | default_cache_serializer_path: '%kernel.cache_dir%/htmlpurifier' 45 | html_profiles: 46 | custom: 47 | config: 48 | Core.Encoding: 'ISO-8859-1' 49 | HTML.Allowed: 'a[href|target],p,br' 50 | Attr.AllowedFrameTargets: '_blank' 51 | ``` 52 | 53 | In this example, a `exercise_html_purifier.custom` service will also be defined, 54 | which includes cache, encoding, HTML tags and attributes options. Available configuration 55 | options may be found in HTMLPurifier's [configuration documentation][]. 56 | 57 | **Note:** If you define a `default` profile but omit `Cache.SerializerPath`, it 58 | will still default to the path above. You can specify a value of `null` for the 59 | option to suppress the default path. 60 | 61 | [configuration documentation]: http://htmlpurifier.org/live/configdoc/plain.html 62 | 63 | ## Autowiring 64 | 65 | By default type hinting `\HtmlPurifier` in your services will autowire 66 | the `exercise_html_purifier.default` service. 67 | To override it and use your own config as default autowired services just add 68 | this configuration: 69 | 70 | ```yaml 71 | # config/services.yaml 72 | services: 73 | #... 74 | 75 | exercise_html_purifier.default: '@exercise_html_purifier.custom' 76 | ``` 77 | 78 | ### Using a custom purifier class as default 79 | 80 | If you want to use your own class as default purifier, define the new alias as 81 | below: 82 | 83 | ```yaml 84 | # config/services.yaml 85 | services: 86 | # ... 87 | 88 | exercise_html_purifier.default: '@App\Html\CustomHtmlPurifier' 89 | ``` 90 | 91 | ### Argument binding 92 | 93 | The bundle also leverages the alias argument binding for each profile. So the 94 | following config: 95 | 96 | ```yaml 97 | html_profiles: 98 | blog: 99 | # ... 100 | gallery: 101 | # ... 102 | ``` 103 | 104 | will register the following binding: 105 | 106 | ```php 107 | // default config is bound whichever argument name is used 108 | public function __construct(\HTMLPurifier $purifier) {} 109 | public function __construct(\HTMLPurifier $htmlPurifier) {} 110 | public function __construct(\HTMLPurifier $blogPurifier) {} // blog config 111 | public function __construct(\HTMLPurifier $galleryPurifier) {} // gallery config 112 | ``` 113 | 114 | ## Form Type Extension 115 | 116 | This bundles provides a form type extension for filtering form fields with 117 | HTMLPurifier. Purification is done early during the PRE_SUBMIT event, which 118 | means that client data will be filtered before being bound to the form. 119 | 120 | Two options are automatically available in all `TextType` based types: 121 | 122 | ```php 123 | add('content', TextareaType::class, ['purify_html' => true]) // will use default profile 138 | ->add('sneek_peak', TextType::class, ['purify_html' => true, 'purify_html_profile' => 'sneak_peak']) 139 | // ... 140 | ; 141 | } 142 | 143 | // ... 144 | } 145 | ``` 146 | 147 | Every type extending `TextType` (i.e: `TextareaType`) inherit these options. 148 | It also means that if you use a type such as [CKEditorType][], you will benefit 149 | from these options without configuring anything. 150 | 151 | [CKEDitorType]: https://github.com/egeloen/IvoryCKEditorBundle/blob/master/Form/Type/CKEditorType.php#L570 152 | 153 | ## Twig Filter 154 | 155 | This bundles registers a `purify` filter with Twig. Output from this filter is 156 | marked safe for HTML, much like Twig's built-in escapers. The filter may be used 157 | as follows: 158 | 159 | ```twig 160 | {# Filters text's value through the "default" HTMLPurifier service #} 161 | {{ text|purify }} 162 | 163 | {# Filters text's value through the "custom" HTMLPurifier service #} 164 | {{ text|purify('custom') }} 165 | ``` 166 | 167 | ## Purifiers Registry 168 | 169 | A `Exercise\HtmlPurifierBundle\HtmlPurifiersRegistry` class is registered by default 170 | as a service. To add your custom instance of purifier, and make it available to 171 | the form type and Twig extensions through its profile name, you can use the tag 172 | `exercise.html_purifier` as follow: 173 | 174 | ```yaml 175 | # config/services.yaml 176 | 177 | services: 178 | # ... 179 | 180 | App\HtmlPurifier\CustomPurifier: 181 | tags: 182 | - name: exercise.html_purifier 183 | profile: custom 184 | ``` 185 | 186 | Now your purifier can be used when: 187 | 188 | ```php 189 | // In a form type 190 | $builder 191 | ->add('content', TextareaType::class, [ 192 | 'purify_html' => true, 193 | 'purify_html_profile' => 'custom', 194 | ]) 195 | // ... 196 | ``` 197 | 198 | ```twig 199 | {# in a template #} 200 | {{ html_string|purify('custom') }} 201 | ``` 202 | 203 | ## How to Customize a Config Definition 204 | 205 | ### Whitelist Attributes 206 | 207 | In some case, you might want to set some rules for a specific tag. 208 | This is what the following config is about: 209 | 210 | ```yaml 211 | # config/packages/exercise_html_purifier.yaml 212 | exercise_html_purifier: 213 | html_profiles: 214 | default: 215 | config: 216 | HTML.Allowed: < 217 | *[id|class|name], 218 | a[href|title|rel|target], 219 | img[src|alt|height|width], 220 | br,div,embed,object,u,em,ul,ol,li,strong,span 221 | attributes: 222 | img: 223 | # attribute name, type (Integer, Color, ...) 224 | data-id: ID 225 | data-image-size: Text 226 | span: 227 | data-link: URI 228 | ``` 229 | 230 | See [HTMLPurifier_AttrTypes][] for more options. 231 | 232 | [HTMLPurifier_AttrTypes]: https://github.com/ezyang/htmlpurifier/blob/master/library/HTMLPurifier/AttrTypes.php 233 | 234 | ### Whitelist Elements 235 | 236 | In some case, you might want to set some rules for a specific tag. 237 | This is what the following config is about: 238 | 239 | ```yaml 240 | # config/packages/exercise_html_purifier.yaml 241 | exercise_html_purifier: 242 | html_profiles: 243 | default: 244 | # ... 245 | elements: 246 | video: 247 | - Block 248 | - 'Optional: (source, Flow) | (Flow, source) | Flow' 249 | - Common # allows a set of common attributes 250 | # The 4th and 5th arguments are optional 251 | - src: URI # list of type rules by attributes 252 | type: Text 253 | width: Length 254 | height: Length 255 | poster: URI 256 | preload: 'Enum#auto,metadata,none' 257 | controls: Bool 258 | source: 259 | - Block 260 | - Flow 261 | - Common 262 | - { src: URI, type: Text } 263 | - [style] # list of forbidden attributes 264 | ``` 265 | 266 | Would be equivalent to: 267 | 268 | ```php 269 | $def = $config->getHTMLDefintion(true); 270 | $def->addElement('video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', [ 271 | 'src' => 'URI', 272 | 'type' => 'Text', 273 | 'width' => 'Length', 274 | 'height' => 'Length', 275 | 'poster' => 'URI', 276 | 'preload' => 'Enum#auto,metadata,none', 277 | 'controls' => 'Bool', 278 | ]); 279 | $source = $def->addElement('source', 'Block', 'Flow', 'Common', [ 280 | 'src' => 'URI', 281 | 'type' => 'Text', 282 | ]); 283 | $source->excludes = ['style' => true]; 284 | ``` 285 | 286 | See [HTMLPurifier documentation][] for more details. 287 | 288 | [HTMLPurifier documentation]: http://htmlpurifier.org/docs/enduser-customize.html 289 | 290 | ### Blank Elements 291 | 292 | It might happen that you need a tag clean from any attributes. 293 | Then just add it to the list: 294 | 295 | ```yaml 296 | # config/packages/exercise_html_purifier.yaml 297 | exercise_html_purifier: 298 | html_profiles: 299 | default: 300 | # ... 301 | blank_elements: [legend, figcaption] 302 | ``` 303 | 304 | ## How to Reuse Profiles 305 | 306 | What can really convenient is to reuse some profile definition 307 | to build other custom definitions. 308 | 309 | ```yaml 310 | # config/packages/exercise_html_purifier.yaml 311 | exercise_html_purifier: 312 | html_profiles: 313 | base: 314 | # ... 315 | video: 316 | # ... 317 | all: 318 | parents: [base, video] 319 | ``` 320 | 321 | In this example the profile named "all" will inherit the "default" profile, 322 | then the two custom ones. The order is important as each profile overrides the 323 | previous, and "all" could define its own rules too. 324 | 325 | ## Contributing 326 | 327 | PRs are welcomed :). Please target the `4.x` branch for bug fixes and `master` 328 | for new features. 329 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exercise/htmlpurifier-bundle", 3 | "type": "symfony-bundle", 4 | "description": "HTMLPurifier integration for your Symfony project", 5 | "keywords": ["htmlpurifier", "html", "purifier", "symfony"], 6 | "homepage": "https://github.com/Exercise/HTMLPurifierBundle", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "contributors", 11 | "homepage": "https://github.com/Exercise/HTMLPurifierBundle/contributors" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.1", 16 | "ezyang/htmlpurifier": "~4.14", 17 | "symfony/config": "^5.4 || ^6.0 || ^7.0", 18 | "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", 19 | "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0" 20 | }, 21 | "require-dev": { 22 | "symfony/form": "^5.4 || ^6.0 || ^7.0", 23 | "symfony/phpunit-bridge": "^7.0.1", 24 | "twig/twig": "^2.4.4 || ^3.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { "Exercise\\HTMLPurifierBundle\\": "src/" } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { "Exercise\\HTMLPurifierBundle\\Tests\\": "tests/" } 31 | }, 32 | "config": { 33 | "sort-packages": true 34 | }, 35 | "extra": { 36 | "branch-alias": { 37 | "dev-master": "5.x-dev" 38 | } 39 | }, 40 | "minimum-stability": "dev" 41 | } 42 | -------------------------------------------------------------------------------- /qa/php-cs-fixer/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "friendsofphp/php-cs-fixer": "^3.38" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/CacheWarmer/SerializerCacheWarmer.php: -------------------------------------------------------------------------------- 1 | 19 | * @author Jules Pietri 20 | */ 21 | class SerializerCacheWarmer implements CacheWarmerInterface 22 | { 23 | private $paths; 24 | private $profiles; 25 | private $registry; 26 | private $filesystem; 27 | 28 | /** 29 | * @param string[] $paths 30 | * @param string[] $profiles 31 | */ 32 | public function __construct(array $paths, array $profiles, HTMLPurifiersRegistryInterface $registry, Filesystem $filesystem) 33 | { 34 | $this->paths = $paths; 35 | $this->profiles = $profiles; 36 | $this->registry = $registry; 37 | $this->filesystem = $filesystem; 38 | } 39 | 40 | public function warmUp($cacheDir, ?string $buildDir = null): array 41 | { 42 | foreach ($this->paths as $path) { 43 | $this->filesystem->remove($path); // clean previous cache 44 | $this->filesystem->mkdir($path); 45 | } 46 | 47 | foreach ($this->profiles as $profile) { 48 | // Will build the configuration 49 | $this->registry->get($profile)->purify("
"); 50 | } 51 | 52 | return []; 53 | } 54 | 55 | public function isOptional(): bool 56 | { 57 | return false; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/HTMLPurifierPass.php: -------------------------------------------------------------------------------- 1 | hasAlias(HTMLPurifiersRegistryInterface::class)) { 20 | return; 21 | } 22 | 23 | try { 24 | $registry = $container->findDefinition(HTMLPurifiersRegistryInterface::class); 25 | } catch (ServiceNotFoundException $e) { 26 | return; 27 | } 28 | 29 | $purifiers = []; 30 | 31 | foreach ($container->findTaggedServiceIds(self::PURIFIER_TAG) as $id => $tags) { 32 | if (empty($tags[0]['profile'])) { 33 | throw new InvalidConfigurationException(sprintf('Tag "%s" must define a "profile" attribute.', self::PURIFIER_TAG)); 34 | } 35 | 36 | $profile = $tags[0]['profile']; 37 | $purifier = $container->getDefinition($id); 38 | 39 | if (empty($purifier->getArguments())) { 40 | $configId = "exercise_html_purifier.config.$profile"; 41 | $config = $container->hasDefinition($configId) ? $configId : 'exercise_html_purifier.config.default'; 42 | 43 | $purifier->addArgument(new Reference($config)); 44 | } 45 | 46 | $purifiers[$profile] = new Reference($id); 47 | } 48 | 49 | $registry->setArguments([ 50 | ServiceLocatorTagPass::register($container, $purifiers), 51 | ]); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 15 | 16 | $rootNode 17 | ->children() 18 | ->scalarNode('default_cache_serializer_path') 19 | ->defaultValue('%kernel.cache_dir%/htmlpurifier') 20 | ->end() 21 | ->scalarNode('default_cache_serializer_permissions') 22 | // (format "%#o" 493) "0755" 23 | ->defaultValue(493) 24 | ->end() 25 | ->arrayNode('html_profiles') 26 | ->useAttributeAsKey('name') 27 | ->normalizeKeys(false) 28 | ->validate() 29 | ->always(function ($profiles) { 30 | foreach ($profiles as $profile => $definition) { 31 | foreach ($definition['parents'] as $parent) { 32 | if (!isset($profiles[$parent])) { 33 | throw new InvalidConfigurationException(sprintf('Invalid parent "%s" is not defined for profile "%s".', $parent, $profile)); 34 | } 35 | } 36 | } 37 | 38 | return $profiles; 39 | }) 40 | ->end() 41 | ->arrayPrototype() 42 | ->children() 43 | ->arrayNode('config') 44 | ->defaultValue([]) 45 | ->info('An array of parameters.') 46 | ->useAttributeAsKey('parameter') 47 | ->normalizeKeys(false) 48 | ->variablePrototype()->end() 49 | ->end() 50 | ->arrayNode('attributes') 51 | ->defaultValue([]) 52 | ->info('Every key is a tag name, with arrays for rules') 53 | ->normalizeKeys(false) 54 | ->useAttributeAsKey('tag_name') 55 | ->arrayPrototype() 56 | ->info('Every key is an attribute name for a rule like "Text"') 57 | ->useAttributeAsKey('attribute_name') 58 | ->normalizeKeys(false) 59 | ->scalarPrototype()->end() 60 | ->end() 61 | ->end() 62 | ->arrayNode('elements') 63 | ->defaultValue([]) 64 | ->info('Every key is a tag name, with an array of four values as definition. The fourth is an optional array of attributes rules.') 65 | ->normalizeKeys(false) 66 | ->useAttributeAsKey('tag_name') 67 | ->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.') 68 | ->arrayPrototype() 69 | ->validate() 70 | ->ifTrue(function ($array) { 71 | $count = count($array); 72 | 73 | return 3 > $count || $count > 5; 74 | }) 75 | ->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.') 76 | ->end() 77 | ->variablePrototype()->end() 78 | ->end() 79 | ->end() 80 | ->arrayNode('blank_elements') 81 | ->defaultValue([]) 82 | ->info('An array of tag names that should purify everything.') 83 | ->scalarPrototype()->end() 84 | ->end() 85 | ->arrayNode('parents') 86 | ->defaultValue([]) 87 | ->info('An array of config names that should be inherited.') 88 | ->scalarPrototype()->end() 89 | ->end() 90 | ->end() 91 | ->end() 92 | ->end() 93 | ->end() 94 | ; 95 | 96 | return $treeBuilder; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/DependencyInjection/ExerciseHTMLPurifierExtension.php: -------------------------------------------------------------------------------- 1 | load('html_purifier.xml'); 22 | 23 | $configs = $this->processConfiguration(new Configuration(), $configs); 24 | 25 | // Set default serializer cache path, while ensuring a default profile is defined 26 | $configs['html_profiles']['default']['config']['Cache.SerializerPath'] = $configs['default_cache_serializer_path']; 27 | 28 | if (!isset($configs['html_profiles']['default']['config']['Cache.SerializerPermissions'])) { 29 | $configs['html_profiles']['default']['config']['Cache.SerializerPermissions'] = $configs['default_cache_serializer_permissions']; 30 | } 31 | 32 | $serializerPaths = []; 33 | // Drop when require Symfony > 3.4 34 | $registerAlias = method_exists($container, 'registerAliasForArgument'); 35 | 36 | foreach ($configs['html_profiles'] as $name => $definition) { 37 | $configId = "exercise_html_purifier.config.$name"; 38 | $default = null; 39 | $parents = []; // stores inherited configs 40 | 41 | if ('default' !== $name) { 42 | $default = new Reference('exercise_html_purifier.config.default'); 43 | $parentNames = $definition['parents']; 44 | 45 | unset($parentNames['default']); // default is always inherited 46 | foreach ($parentNames as $parentName) { 47 | self::resolveProfileInheritance($parentName, $configs['html_profiles'], $parents); 48 | } 49 | } 50 | 51 | $container->register($configId, \HTMLPurifier_Config::class) 52 | ->setFactory([HTMLPurifierConfigFactory::class, 'create']) 53 | ->setArguments([ 54 | $name, 55 | $definition['config'], 56 | $default, 57 | self::getResolvedConfig('config', $parents), 58 | self::getResolvedConfig('attributes', $parents, $definition), 59 | self::getResolvedConfig('elements', $parents, $definition), 60 | self::getResolvedConfig('blank_elements', $parents, $definition), 61 | ]) 62 | ; 63 | 64 | $id = "exercise_html_purifier.$name"; 65 | $container->register($id, \HTMLPurifier::class) 66 | ->setArguments([new Reference($configId)]) 67 | ->addTag(HTMLPurifierPass::PURIFIER_TAG, ['profile' => $name]) 68 | ; 69 | 70 | if (isset($definition['config']['Cache.SerializerPath'])) { 71 | $serializerPaths[] = $definition['config']['Cache.SerializerPath']; 72 | } 73 | 74 | if ($registerAlias && $default) { 75 | $container->registerAliasForArgument($id, \HTMLPurifier::class, "$name.purifier"); 76 | } 77 | } 78 | 79 | $container->register('exercise_html_purifier.purifiers_registry', HTMLPurifiersRegistry::class) 80 | ->setPublic(false) 81 | ; 82 | $container->setAlias(HTMLPurifiersRegistryInterface::class, 'exercise_html_purifier.purifiers_registry') 83 | ->setPublic(false) 84 | ; 85 | $container->setAlias(\HTMLPurifier::class, 'exercise_html_purifier.default') 86 | ->setPublic(false) 87 | ; 88 | $container->getDefinition('exercise_html_purifier.cache_warmer.serializer') 89 | ->setArgument(0, array_unique($serializerPaths)) 90 | ->setArgument(1, array_keys($configs['html_profiles'])) 91 | ; 92 | } 93 | 94 | public function getAlias(): string 95 | { 96 | return 'exercise_html_purifier'; 97 | } 98 | 99 | private static function resolveProfileInheritance(string $parent, array $configs, array &$resolved): void 100 | { 101 | if (isset($resolved[$parent])) { 102 | // Another profile already inherited this config, skip 103 | return; 104 | } 105 | 106 | foreach ($configs[$parent]['parents'] as $grandParent) { 107 | self::resolveProfileInheritance($grandParent, $configs, $resolved); 108 | } 109 | 110 | $resolved[$parent]['config'] = $configs[$parent]['config']; 111 | $resolved[$parent]['attributes'] = $configs[$parent]['attributes']; 112 | $resolved[$parent]['elements'] = $configs[$parent]['elements']; 113 | $resolved[$parent]['blank_elements'] = $configs[$parent]['blank_elements']; 114 | } 115 | 116 | private static function getResolvedConfig(string $parameter, array $parents, ?array $definition = null): array 117 | { 118 | if (null !== $definition) { 119 | return array_filter(array_merge( 120 | array_column($parents, $parameter), 121 | isset($definition[$parameter]) ? $definition[$parameter] : [] 122 | )); 123 | } 124 | 125 | return array_filter(array_column($parents, $parameter)); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/ExerciseHTMLPurifierBundle.php: -------------------------------------------------------------------------------- 1 | addCompilerPass(new HTMLPurifierPass()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Form/Listener/HTMLPurifierListener.php: -------------------------------------------------------------------------------- 1 | registry = $registry; 18 | $this->profile = $profile; 19 | } 20 | 21 | public function purifySubmittedData(FormEvent $event): void 22 | { 23 | if (!is_scalar($data = $event->getData())) { 24 | // Hope there is a view transformer, otherwise an error might happen 25 | return; // because we don't want to handle it here 26 | } 27 | 28 | if (0 === strlen($submittedData = trim((string) $data))) { 29 | if ($submittedData !== $data) { 30 | $event->setData($submittedData); 31 | } 32 | 33 | return; 34 | } 35 | 36 | $event->setData($this->getPurifier()->purify($submittedData)); 37 | } 38 | 39 | public static function getSubscribedEvents(): array 40 | { 41 | return [ 42 | FormEvents::PRE_SUBMIT => ['purifySubmittedData', /* as soon as possible */ 1000000], 43 | ]; 44 | } 45 | 46 | private function getPurifier(): \HTMLPurifier 47 | { 48 | return $this->registry->get($this->profile); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Form/TypeExtension/HTMLPurifierTextTypeExtension.php: -------------------------------------------------------------------------------- 1 | purifiersRegistry = $registry; 21 | } 22 | 23 | public static function getExtendedTypes(): iterable 24 | { 25 | return [TextType::class]; 26 | } 27 | 28 | public function configureOptions(OptionsResolver $resolver): void 29 | { 30 | $resolver 31 | ->setDefaults([ 32 | 'purify_html' => false, 33 | 'purify_html_profile' => 'default', 34 | ]) 35 | ->setAllowedTypes('purify_html', 'bool') 36 | ->setAllowedTypes('purify_html_profile', ['string', 'null']) 37 | ->setNormalizer('purify_html_profile', function (Options $options, $profile) { 38 | if (!$options['purify_html']) { 39 | return null; 40 | } 41 | 42 | if ($this->purifiersRegistry->has($profile)) { 43 | return $profile; 44 | } 45 | 46 | throw new InvalidOptionsException(sprintf('The profile "%s" is not registered.', $profile)); 47 | }) 48 | ->setNormalizer('trim', function (Options $options, $trim) { 49 | // trim is done in the HTMLPurifierListener 50 | return $options['purify_html'] ? false : $trim; 51 | }) 52 | ; 53 | } 54 | 55 | public function buildForm(FormBuilderInterface $builder, array $options): void 56 | { 57 | if ($options['purify_html']) { 58 | $builder->addEventSubscriber( 59 | new HTMLPurifierListener($this->purifiersRegistry, $options['purify_html_profile']) 60 | ); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/HTMLPurifierConfigFactory.php: -------------------------------------------------------------------------------- 1 | ['src' => 'URI', 'data-type' => Text']] ] 17 | * @param array $elements An array of arrays by element to add or override, arrays must 18 | * hold a type ("Inline, "Block", ...), a content type ("Empty", 19 | * "Optional: #PCDATA", ...), an attributes set ("Core", "Common", 20 | * ...), a fourth optional may define attributes rules as array, and 21 | * a fifth to list forbidden attributes 22 | * @param array $blankElements An array of tag names that should not have any attributes 23 | */ 24 | public static function create( 25 | string $profile, 26 | array $configArray, 27 | ?\HTMLPurifier_Config $defaultConfig = null, 28 | array $parents = [], 29 | array $attributes = [], 30 | array $elements = [], 31 | array $blankElements = [] 32 | ): \HTMLPurifier_Config { 33 | if ($defaultConfig) { 34 | $config = \HTMLPurifier_Config::inherit($defaultConfig); 35 | } else { 36 | $config = \HTMLPurifier_Config::createDefault(); 37 | } 38 | 39 | foreach ($parents as $parent) { 40 | $config->loadArray($parent); 41 | } 42 | 43 | $config->loadArray($configArray); 44 | 45 | // Make the config unique 46 | $config->set('HTML.DefinitionID', $profile); 47 | $config->set('HTML.DefinitionRev', 1); 48 | 49 | $def = $config->maybeGetRawHTMLDefinition(); 50 | 51 | // If the definition is not cached, build it 52 | if ($def && ($attributes || $elements || $blankElements)) { 53 | static::buildHTMLDefinition($def, $attributes, $elements, $blankElements); 54 | } 55 | 56 | return $config; 57 | } 58 | 59 | /** 60 | * Builds a config definition from the given parameters. 61 | * 62 | * This build should never happen on runtime, since purifiers cache should 63 | * be generated during warm up. 64 | */ 65 | public static function buildHTMLDefinition(\HTMLPurifier_HTMLDefinition $def, array $attributes, array $elements, array $blankElements): void 66 | { 67 | foreach ($attributes as $elementName => $rule) { 68 | foreach ($rule as $attributeName => $definition) { 69 | /* @see \HTMLPurifier_AttrTypes */ 70 | $def->addAttribute($elementName, $attributeName, $definition); 71 | } 72 | } 73 | 74 | foreach ($elements as $elementName => $config) { 75 | /* @see \HTMLPurifier_HTMLModule::addElement() */ 76 | $el = $def->addElement($elementName, $config[0], $config[1], $config[2], isset($config[3]) ? $config[3] : []); 77 | 78 | if (isset($config[4])) { 79 | $el->excludes = array_fill_keys($config[4], true); 80 | } 81 | } 82 | 83 | foreach ($blankElements as $blankElement) { 84 | /* @see \HTMLPurifier_HTMLModule::addBlankElement() */ 85 | $def->addBlankElement($blankElement); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/HTMLPurifiersRegistry.php: -------------------------------------------------------------------------------- 1 | purifiersLocator = $purifiersLocator; 14 | } 15 | 16 | public function has(string $profile): bool 17 | { 18 | return $this->purifiersLocator->has($profile); 19 | } 20 | 21 | public function get(string $profile): \HTMLPurifier 22 | { 23 | return $this->purifiersLocator->get($profile); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/HTMLPurifiersRegistryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Twig/HTMLPurifierExtension.php: -------------------------------------------------------------------------------- 1 | ['html']]), 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Twig/HTMLPurifierRuntime.php: -------------------------------------------------------------------------------- 1 | purifiersRegistry = $registry; 15 | } 16 | 17 | /** 18 | * Filters the input through an \HTMLPurifier service. 19 | * 20 | * @param string|null $string The html string to purify 21 | * @param string $profile A configuration profile name 22 | * 23 | * @return string The purified html string 24 | */ 25 | public function purify(?string $string, string $profile = 'default'): string 26 | { 27 | if (null === $string) { 28 | return ''; 29 | } 30 | 31 | return $this->getHTMLPurifierForProfile($profile)->purify($string); 32 | } 33 | 34 | /** 35 | * Gets the HTMLPurifier service corresponding to the given profile. 36 | * 37 | * @throws \InvalidArgumentException If the profile does not exist 38 | */ 39 | private function getHTMLPurifierForProfile(string $profile): \HTMLPurifier 40 | { 41 | return $this->purifiersRegistry->get($profile); 42 | } 43 | } 44 | --------------------------------------------------------------------------------