diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2b7826010..e96ef195535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ > [!IMPORTANT] > This update contains breaking changes for plugins. See [#19263](https://github.com/craftcms/cms/pull/19263) for details. +- Replaced `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers` with `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager`. HTML sanitizers should now be registered via `CraftCms\Cms\Support\Facades\HtmlSanitizers::extend()` rather than `CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers::register()`. ([#19292](https://github.com/craftcms/cms/pull/19292)) - Plugins should no longer define `extra.laravel.providers` in `composer.json`. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Removed automatic plugin trait lifecycle hooks. ([#19263](https://github.com/craftcms/cms/pull/19263)) - Added `CraftCms\Cms\Cp\Components\Button`. ([#19248](https://github.com/craftcms/cms/pull/19248)) diff --git a/docs/html-sanitizers.md b/docs/html-sanitizers.md index 03a46bcfb3e..2472bb94f43 100644 --- a/docs/html-sanitizers.md +++ b/docs/html-sanitizers.md @@ -1,20 +1,18 @@ # HTML Sanitizers -Craft 6 includes a Laravel-native HTML sanitizer registry built on [Symfony HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html). +Craft 6 includes a Laravel-native `HtmlSanitizerManager` built on [Symfony HtmlSanitizer](https://symfony.com/doc/current/html_sanitizer.html). Application code can access it through the plural `HtmlSanitizers` facade. -The `HtmlSanitizers` service lets plugins and apps: +The manager lets plugins and apps: -- sanitize HTML with Craft's default sanitizer +- sanitize HTML with Craft’s default sanitizer - register named sanitizers for project-specific rules - customize the default sanitizer config -- reuse Craft's default config when defining custom sanitizers +- reuse Craft’s default config when defining custom sanitizers -Legacy HtmlPurifier support remains available through the Yii2 adapter layer. The legacy Twig `|purify` filter still uses HtmlPurifier, while the new `|sanitize` filter uses the new sanitizer registry. +Legacy HtmlPurifier support remains available through the Yii2 adapter layer. The legacy Twig `|purify` filter still uses HtmlPurifier, while `|sanitize` uses the sanitizer manager. ## Basic Usage -Use the facade and call `sanitize()`: - ```php register('links-only', new HtmlSanitizer( + HtmlSanitizers::extend('links-only', new HtmlSanitizer( (new HtmlSanitizerConfig()) ->allowElement('a') ->allowAttribute('href', ['a']) @@ -63,29 +50,32 @@ class AppServiceProvider extends ServiceProvider } ``` -Use the registered sanitizer by name: +`extend()` accepts any of these definitions: -```php - [ + 'allow_elements' => [ + 'a' => ['href'], + ], +]); ``` -You can also resolve the sanitizer instance directly: +Use a sanitizer by name or resolve it directly: ```php -sanitize($dirtyHtml); ``` ## Array Config Files -Named sanitizers can also be registered with Symfony-style array config files in `config/craft/sanitizers/`. The file name becomes the sanitizer name. +Named sanitizers can also be defined in `config/craft/sanitizers/`. The file name becomes the sanitizer name and the returned array is loaded through Laravel’s configuration repository, including when configuration is cached. ```php defaults(fn (HtmlSanitizerConfig $config) => $config - ->allowAttribute('class', ['p', 'span']) - ->allowElement('iframe') - ->allowAttribute('src', ['iframe']) - ); - } -} +HtmlSanitizers::defaults(static fn (HtmlSanitizerConfig $config) => $config + ->allowAttribute('class', ['p', 'span']) + ->allowElement('iframe') + ->allowAttribute('src', ['iframe']) +); ``` -Each callback receives the current default config and may return the same config or a modified one. - -The default sanitizer instance is built lazily from `defaultConfig()`, so calling `defaults()` changes what `sanitize($html)` uses by default. - -## Reusing the Default Config - -If you want a custom named sanitizer that starts from Craft's defaults, call `defaultConfig()` and keep building from there. +To create a named sanitizer starting from Craft’s defaults: ```php -defaultConfig() - ->allowElement('iframe') - ->allowAttribute('src', ['iframe']); - - $sanitizers->register('embedded-content', new HtmlSanitizer($config)); - } -} +HtmlSanitizers::extend('embedded-content', fn () => new HtmlSanitizer( + HtmlSanitizers::defaultConfig() + ->allowElement('iframe') + ->allowAttribute('src', ['iframe']) +)); ``` -This is the recommended way to define custom sanitizers that should stay close to Craft's defaults. - -## Twig Filters +## Resolution and Caching -Craft now provides two distinct Twig filters: +Sanitizers are created lazily and cached by name. Register extensions and default callbacks before their sanitizers are first resolved. Replacing a creator or adding a default callback does not change an already-resolved sanitizer. Call `HtmlSanitizers::forgetDrivers()` to clear all resolved instances when an intentional runtime change must take effect. -- `|sanitize` uses the new `HtmlSanitizers` service +Calling `all()` eagerly resolves every registered sanitizer. `getDrivers()` returns only sanitizers that have already been resolved. -Examples: +## Twig Filter ```twig {{ body|sanitize }} @@ -175,11 +131,3 @@ Examples: ``` You may also pass a concrete sanitizer instance from PHP into a template context and use it directly with `|sanitize`. - -## Recommended Direction - -For new code: - -- prefer the `HtmlSanitizers` service or facade for application code -- prefer `|sanitize` in Twig -- define named sanitizers in service providers when they need custom PHP logic diff --git a/resources/js/common/layouts/AuthBase.vue b/resources/js/common/layouts/AuthBase.vue index c675589546f..97ab5cbb056 100644 --- a/resources/js/common/layouts/AuthBase.vue +++ b/resources/js/common/layouts/AuthBase.vue @@ -82,7 +82,7 @@ .cp-login__powered-by { display: block; margin-block-start: calc(70rem / 16); - opacity: 0.92;; + opacity: 0.92; text-align: center; &:hover, diff --git a/src/Config/ConfigServiceProvider.php b/src/Config/ConfigServiceProvider.php index 8978c08aea7..af33242a07c 100644 --- a/src/Config/ConfigServiceProvider.php +++ b/src/Config/ConfigServiceProvider.php @@ -6,12 +6,12 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Support\Env; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use CraftCms\Cms\Support\Typecast; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables; -use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; +use InvalidArgumentException; use Override; class ConfigServiceProvider extends ServiceProvider @@ -128,18 +128,19 @@ private function applyGeneralConfigSetting(GeneralConfig $config, string $settin private function loadHtmlSanitizers(): void { - $path = config_path('craft/sanitizers'); + $sanitizers = $this->app->make(HtmlSanitizerManager::class); + $definitions = $this->app->make(ConfigRepository::class)->get('craft.sanitizers', []); - if (! File::isDirectory($path)) { - return; + if (! is_array($definitions)) { + throw new InvalidArgumentException('The [craft.sanitizers] configuration must be an array.'); } - $sanitizers = $this->app->make(HtmlSanitizers::class); - - foreach (File::files($path) as $file) { - if ($file->getExtension() === 'php') { - $sanitizers->register($file->getFilenameWithoutExtension(), require $file->getRealPath()); + foreach ($definitions as $name => $definition) { + if (! is_string($name) || ! is_array($definition)) { + throw new InvalidArgumentException('HTML sanitizer configuration definitions must be named arrays.'); } + + $sanitizers->extend($name, $definition); } } } diff --git a/src/Field/Data/MarkdownData.php b/src/Field/Data/MarkdownData.php index 8f00d77cf1b..6acb9ed8795 100644 --- a/src/Field/Data/MarkdownData.php +++ b/src/Field/Data/MarkdownData.php @@ -6,9 +6,9 @@ use CraftCms\Cms\Shared\Contracts\Serializable; use CraftCms\Cms\Support\Facades\Elements; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Facades\Markdown as MarkdownFacade; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; use CraftCms\Cms\Twig\Contracts\SafeHtml; use Illuminate\Contracts\Support\Htmlable; @@ -64,7 +64,7 @@ public function getHtml(): string return $html; } - return app(HtmlSanitizers::class)->sanitize($html, $this->htmlSanitizer); + return HtmlSanitizers::sanitize($html, $this->htmlSanitizer); } #[AllowedInSandbox] diff --git a/src/Field/Markdown.php b/src/Field/Markdown.php index d3a27d5b2eb..89b2b231d85 100644 --- a/src/Field/Markdown.php +++ b/src/Field/Markdown.php @@ -21,11 +21,11 @@ use CraftCms\Cms\Markdown\Markdown as MarkdownService; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Folders; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Facades\Volumes; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\Support\Str; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; @@ -381,7 +381,7 @@ protected function supportedLinkAdvancedFields(): array private function htmlSanitizerOptions(): Collection { - return collect(app(HtmlSanitizers::class)->names()) + return collect(HtmlSanitizers::names()) ->map(fn (string $name) => [ 'label' => $name === 'default' ? t('Default') : $name, 'value' => $name, diff --git a/src/Http/Controllers/App/RenderController.php b/src/Http/Controllers/App/RenderController.php index fc8b3c2bd87..15adc587daa 100644 --- a/src/Http/Controllers/App/RenderController.php +++ b/src/Http/Controllers/App/RenderController.php @@ -18,8 +18,8 @@ use CraftCms\Cms\Field\Markdown as MarkdownField; use CraftCms\Cms\Markdown\Markdown as MarkdownService; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\Support\Typecast; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -180,7 +180,7 @@ public function markdown(Request $request): JsonResponse 'encode' => ['boolean'], 'inlineOnly' => ['boolean'], 'sanitizeHtml' => ['boolean'], - 'htmlSanitizer' => ['nullable', 'string', Rule::in(app(HtmlSanitizers::class)->names())], + 'htmlSanitizer' => ['nullable', 'string', Rule::in(HtmlSanitizers::names())], ]); $encode = $request->boolean('encode'); diff --git a/src/Support/Facades/HtmlSanitizers.php b/src/Support/Facades/HtmlSanitizers.php index c4298f07eee..79c973d1020 100644 --- a/src/Support/Facades/HtmlSanitizers.php +++ b/src/Support/Facades/HtmlSanitizers.php @@ -4,26 +4,30 @@ namespace CraftCms\Cms\Support\Facades; +use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use Illuminate\Support\Facades\Facade; use Override; /** - * @method static void register(string $name, \Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|array $definition) - * @method static void defaults(\Closure $callback) + * @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager extend(string $name, \Closure|\Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|array $definition) + * @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager defaults(\Closure $callback) * @method static bool has(string $name) * @method static array names() * @method static \Illuminate\Support\Collection all() * @method static string sanitize(string $html, \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface|string|null $sanitizer = null) * @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface sanitizer(string|null $name = null) + * @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface driver(string|null $driver = null) * @method static \Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig defaultConfig() + * @method static array getDrivers() + * @method static \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager forgetDrivers() * - * @see \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers + * @see HtmlSanitizerManager */ class HtmlSanitizers extends Facade { #[Override] protected static function getFacadeAccessor(): string { - return \CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers::class; + return HtmlSanitizerManager::class; } } diff --git a/src/Support/Facades/Template.php b/src/Support/Facades/Template.php index 65669fd5ed7..db70830d8e4 100644 --- a/src/Support/Facades/Template.php +++ b/src/Support/Facades/Template.php @@ -30,7 +30,7 @@ * @method static \CraftCms\Cms\View\TemplateManager setContainer(\Illuminate\Contracts\Container\Container $container) * @method static \CraftCms\Cms\View\TemplateManager forgetDrivers() * - * @see \CraftCms\Cms\View\TemplateManager + * @see TemplateManager */ class Template extends Facade { diff --git a/src/Support/HtmlSanitizer/HtmlSanitizers.php b/src/Support/HtmlSanitizer/HtmlSanitizerManager.php similarity index 69% rename from src/Support/HtmlSanitizer/HtmlSanitizers.php rename to src/Support/HtmlSanitizer/HtmlSanitizerManager.php index d962d7cc3b4..c8c202c6a35 100644 --- a/src/Support/HtmlSanitizer/HtmlSanitizers.php +++ b/src/Support/HtmlSanitizer/HtmlSanitizerManager.php @@ -8,57 +8,69 @@ use CraftCms\Cms\Support\HtmlSanitizer\AttributeSanitizers\VideoEmbedUrlSanitizer; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Collection; -use InvalidArgumentException; +use Illuminate\Support\Manager; +use Override; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerAction; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface; +use UnexpectedValueException; #[Singleton] -class HtmlSanitizers +class HtmlSanitizerManager extends Manager { - /** @var array|Closure|HtmlSanitizerInterface> */ - private array $definitions = []; - /** @var list */ private array $defaultCallbacks = []; - /** @var array */ - private array $resolvedSanitizers = []; - - public function __construct() + public function getDefaultDriver(): string { - $this->definitions['default'] = fn () => new HtmlSanitizer($this->defaultConfig()); + return 'default'; } - public function register(string $name, array|Closure|HtmlSanitizerInterface $definition): void + /** + * @param string $driver + */ + #[Override] + public function extend($driver, array|Closure|HtmlSanitizerInterface $definition): static { - $this->definitions[$name] = $definition; - unset($this->resolvedSanitizers[$name]); + $creator = $definition instanceof Closure + ? $definition + : static fn () => $definition; + + parent::extend($driver, $creator); + + return $this; } - public function defaults(Closure $callback): void + public function defaults(Closure $callback): static { $this->defaultCallbacks[] = $callback; - unset($this->resolvedSanitizers['default']); + + return $this; } public function has(string $name): bool { - return isset($this->definitions[$name]); + if ($name === $this->getDefaultDriver()) { + return true; + } + + return array_key_exists($name, $this->customCreators); } /** @return list */ public function names(): array { - return array_keys($this->definitions); + return array_values(array_unique([ + $this->getDefaultDriver(), + ...array_keys($this->customCreators), + ])); } /** @return Collection */ public function all(): Collection { - return collect($this->definitions) - ->keys() + return collect($this->names()) ->mapWithKeys(fn (string $name) => [$name => $this->sanitizer($name)]); } @@ -77,19 +89,16 @@ public function sanitize(string $html, HtmlSanitizerInterface|string|null $sanit public function sanitizer(?string $name = null): HtmlSanitizerInterface { - $name ??= 'default'; - - if (isset($this->resolvedSanitizers[$name])) { - return $this->resolvedSanitizers[$name]; - } - - if (! isset($this->definitions[$name])) { - throw new InvalidArgumentException("Unknown HTML sanitizer [$name]."); - } - - $sanitizer = $this->resolveDefinition($this->definitions[$name]); + return $this->driver($name); + } - return $this->resolvedSanitizers[$name] = $sanitizer; + /** + * @param string|null $driver + */ + #[Override] + public function driver($driver = null): HtmlSanitizerInterface + { + return parent::driver($driver); } public function defaultConfig(): HtmlSanitizerConfig @@ -107,27 +116,35 @@ public function defaultConfig(): HtmlSanitizerConfig foreach ($this->defaultCallbacks as $callback) { $resolvedConfig = $callback($config); - if ($resolvedConfig instanceof HtmlSanitizerConfig) { - $config = $resolvedConfig; + if (! $resolvedConfig instanceof HtmlSanitizerConfig) { + throw new UnexpectedValueException('HTML sanitizer default callbacks must return '.HtmlSanitizerConfig::class.'.'); } + + $config = $resolvedConfig; } return $config; } - private function resolveDefinition(array|Closure|HtmlSanitizerInterface $definition): HtmlSanitizerInterface + #[Override] + protected function createDriver($driver): HtmlSanitizerInterface { - $resolvedSanitizer = value($definition); + $definition = parent::createDriver($driver); - if ($resolvedSanitizer instanceof HtmlSanitizerInterface) { - return $resolvedSanitizer; + if ($definition instanceof HtmlSanitizerInterface) { + return $definition; } - if (is_array($resolvedSanitizer)) { - return new HtmlSanitizer($this->configFromArray($resolvedSanitizer)); + if (is_array($definition)) { + return new HtmlSanitizer($this->configFromArray($definition)); } - throw new InvalidArgumentException('HTML sanitizer definitions must resolve to array configs or HtmlSanitizerInterface instances.'); + throw new UnexpectedValueException("HTML sanitizer [$driver] must resolve to an array config or an instance of ".HtmlSanitizerInterface::class.'.'); + } + + protected function createDefaultDriver(): HtmlSanitizerInterface + { + return new HtmlSanitizer($this->defaultConfig()); } private function configFromArray(array $settings): HtmlSanitizerConfig diff --git a/src/Twig/Extensions/HtmlTwigExtension.php b/src/Twig/Extensions/HtmlTwigExtension.php index b2cb196b14b..c153f74a138 100644 --- a/src/Twig/Extensions/HtmlTwigExtension.php +++ b/src/Twig/Extensions/HtmlTwigExtension.php @@ -12,8 +12,8 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Deprecator; use CraftCms\Cms\Support\Facades\Elements; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\View\InputNamespace; use Illuminate\Support\Facades\Log; use InvalidArgumentException; @@ -112,7 +112,7 @@ public function sanitizeFilter(?string $html, HtmlSanitizerInterface|string|null return null; } - return app(HtmlSanitizers::class)->sanitize($html, $sanitizer); + return HtmlSanitizers::sanitize($html, $sanitizer); } public function removeClassFilter(string $tag, array|string $class): string diff --git a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_customized_with_a_callback.snap b/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_customized_with_a_callback.snap deleted file mode 100644 index bee75ad1b03..00000000000 --- a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_customized_with_a_callback.snap +++ /dev/null @@ -1 +0,0 @@ -

Hello

\ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_reused_for_custom_sanitizers.snap b/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_reused_for_custom_sanitizers.snap deleted file mode 100644 index 20fd6bc5ed1..00000000000 --- a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_config_can_be_reused_for_custom_sanitizers.snap +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_sanitizer_removes_unknown_attributes_and_keeps_craft_additions.snap b/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_sanitizer_removes_unknown_attributes_and_keeps_craft_additions.snap deleted file mode 100644 index 1d621928ab1..00000000000 --- a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/default_sanitizer_removes_unknown_attributes_and_keeps_craft_additions.snap +++ /dev/null @@ -1 +0,0 @@ -
Link \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/registered_sanitizer_names_can_be_used.snap b/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/registered_sanitizer_names_can_be_used.snap deleted file mode 100644 index c2564535d9a..00000000000 --- a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/registered_sanitizer_names_can_be_used.snap +++ /dev/null @@ -1 +0,0 @@ -Craft \ No newline at end of file diff --git a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/sanitizer_instances_can_be_used_directly.snap b/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/sanitizer_instances_can_be_used_directly.snap deleted file mode 100644 index bee75ad1b03..00000000000 --- a/tests/.pest/snapshots/Unit/Support/HtmlSanitizer/HtmlSanitizersTest/sanitizer_instances_can_be_used_directly.snap +++ /dev/null @@ -1 +0,0 @@ -

Hello

\ No newline at end of file diff --git a/tests/Feature/Field/MarkdownFieldTest.php b/tests/Feature/Field/MarkdownFieldTest.php index 6a5a9c44587..76983daee93 100644 --- a/tests/Feature/Field/MarkdownFieldTest.php +++ b/tests/Feature/Field/MarkdownFieldTest.php @@ -14,8 +14,8 @@ use CraftCms\Cms\Markdown\Flavors\GfmFlavor; use CraftCms\Cms\Markdown\Markdown as MarkdownService; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Facades\Volumes; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\View\TemplateManager; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\NameNode; @@ -294,7 +294,7 @@ function markdownFieldResolveInfo(string $fieldName): ResolveInfo }); it('sanitizes rendered html with the configured html sanitizer', function () { - app(HtmlSanitizers::class)->register('paragraphs-only', new HtmlSanitizer( + HtmlSanitizers::extend('paragraphs-only', new HtmlSanitizer( (new HtmlSanitizerConfig)->allowElement('p') )); @@ -306,15 +306,14 @@ function markdownFieldResolveInfo(string $fieldName): ResolveInfo ]); $value = $field->normalizeValueFromRequest('

Hi

Heading

', null); - $previewHtml = $field->getPreviewHtml($value, new Entry); - $inputHtml = $field->getInputHtml('**bold**', null); - - expect($field->validate())->toBeTrue() - ->and($value->getRaw())->toBe('

Hi

Heading

') - ->and($value->getHtml())->toBe("

Hi

\n") - ->and($previewHtml)->toBe("

Hi

\n
") - ->and($inputHtml)->toContain('sanitize-html') - ->and($inputHtml)->toContain('html-sanitizer="paragraphs-only"'); + $preview = new Crawler($field->getPreviewHtml($value, new Entry)); + $input = new Crawler($field->getInputHtml('**bold**', null)); + + expect($value->getHtml())->toBe("

Hi

\n") + ->and($preview->filter('.markdown-field-preview > p')->text())->toBe('Hi') + ->and($preview->filter('[onclick], h1')->count())->toBe(0) + ->and($input->filter('craft-markdown-field[sanitize-html]')->count())->toBe(1) + ->and($input->filter('craft-markdown-field')->attr('html-sanitizer'))->toBe('paragraphs-only'); }); it('preserves raw markdown syntax when html sanitization is enabled', function () { @@ -360,7 +359,7 @@ function markdownFieldResolveInfo(string $fieldName): ResolveInfo }); it('validates and renders html sanitizer settings', function () { - app(HtmlSanitizers::class)->register('paragraphs-only', new HtmlSanitizer( + HtmlSanitizers::extend('paragraphs-only', new HtmlSanitizer( (new HtmlSanitizerConfig)->allowElement('p') )); @@ -380,11 +379,11 @@ function markdownFieldResolveInfo(string $fieldName): ResolveInfo 'sanitizeHtml' => false, 'htmlSanitizer' => 'missing', ]); + $settings = new Crawler($field->getSettingsHtml()); expect($field->validate())->toBeTrue() - ->and($field->getSettingsHtml())->toContain('name="sanitizeHtml"') - ->and($field->getSettingsHtml())->toContain('name="htmlSanitizer"') - ->and($field->getSettingsHtml())->toContain('value="paragraphs-only" selected') + ->and($settings->filter('input[name="sanitizeHtml"]')->count())->toBe(1) + ->and($settings->filter('select[name="htmlSanitizer"] option[selected]')->attr('value'))->toBe('paragraphs-only') ->and($invalidField->validate())->toBeFalse() ->and($invalidField->errors()->has('htmlSanitizer'))->toBeTrue() ->and($disabledField->validate())->toBeTrue(); diff --git a/tests/Feature/Http/Controllers/App/RenderControllerTest.php b/tests/Feature/Http/Controllers/App/RenderControllerTest.php index 1dcee4fd8c0..75abff93fc0 100644 --- a/tests/Feature/Http/Controllers/App/RenderControllerTest.php +++ b/tests/Feature/Http/Controllers/App/RenderControllerTest.php @@ -7,9 +7,9 @@ use CraftCms\Cms\Http\Controllers\App\RenderController; use CraftCms\Cms\Markdown\Markdown as MarkdownService; use CraftCms\Cms\Section\Models\Section; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Facades\Markdown; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; use CraftCms\Cms\User\Elements\User; use Illuminate\Testing\Fluent\AssertableJson; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; @@ -158,7 +158,7 @@ }); test('render markdown sanitizes preview html when requested', function () { - app(HtmlSanitizers::class)->register('paragraphs-only', new HtmlSanitizer( + HtmlSanitizers::extend('paragraphs-only', new HtmlSanitizer( (new HtmlSanitizerConfig)->allowElement('p') )); diff --git a/tests/Unit/Field/Data/MarkdownDataTest.php b/tests/Unit/Field/Data/MarkdownDataTest.php index b94531b6806..ed5f94a9af9 100644 --- a/tests/Unit/Field/Data/MarkdownDataTest.php +++ b/tests/Unit/Field/Data/MarkdownDataTest.php @@ -4,7 +4,7 @@ use CraftCms\Cms\Field\Data\MarkdownData; use CraftCms\Cms\Support\Facades\Elements; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Twig\Contracts\SafeHtml; use Illuminate\Contracts\Support\Htmlable; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; @@ -47,7 +47,7 @@ }); it('can sanitize rendered html', function () { - app(HtmlSanitizers::class)->register('paragraphs-only', new HtmlSanitizer( + HtmlSanitizers::extend('paragraphs-only', new HtmlSanitizer( (new HtmlSanitizerConfig)->allowElement('p') )); diff --git a/tests/Unit/Support/HtmlSanitizer/HtmlSanitizerManagerTest.php b/tests/Unit/Support/HtmlSanitizer/HtmlSanitizerManagerTest.php new file mode 100644 index 00000000000..e974d2dccd3 --- /dev/null +++ b/tests/Unit/Support/HtmlSanitizer/HtmlSanitizerManagerTest.php @@ -0,0 +1,188 @@ +sanitizers = app(HtmlSanitizerManager::class); +}); + +test('the default sanitizer preserves Craft additions and is cached', function () { + $sanitizer = $this->sanitizers->sanitizer(); + + expect($sanitizer) + ->toBeInstanceOf(HtmlSanitizerInterface::class) + ->toBe($this->sanitizers->driver('default')) + ->and($this->sanitizers->sanitize('
')) + ->toBe('
'); +}); + +test('sanitizers can be extended with supported definition forms', function (array|Closure|HtmlSanitizerInterface $definition) { + $this->sanitizers->extend('paragraphs', $definition); + + expect($this->sanitizers->sanitize('

Hello

Heading

', 'paragraphs')) + ->toBe('

Hello

'); +})->with([ + 'array' => [[ + 'allow_elements' => ['p' => ['class']], + ]], + 'instance' => [new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('p', ['class']))], + 'closure returning an array' => [fn () => [ + 'allow_elements' => ['p' => ['class']], + ]], + 'closure returning an instance' => [fn () => new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('p', ['class']))], +]); + +test('late replacements take effect after drivers are forgotten', function () { + $first = new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('p')); + $replacement = new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('h1')); + + $this->sanitizers->extend('custom', $first); + + expect($this->sanitizers->sanitizer('custom'))->toBe($first); + + $this->sanitizers->extend('custom', $replacement); + + expect($this->sanitizers->sanitizer('custom'))->toBe($first); + + $this->sanitizers->forgetDrivers(); + + expect($this->sanitizers->sanitizer('custom'))->toBe($replacement); +}); + +test('creator callbacks use Laravel manager binding and receive the container', function () { + $manager = $this->sanitizers; + $callbackManager = null; + $callbackContainer = null; + + $manager->extend('custom', function (Container $container) use (&$callbackManager, &$callbackContainer) { + $callbackManager = $this; + $callbackContainer = $container; + + return new HtmlSanitizer(new HtmlSanitizerConfig); + }); + + $manager->sanitizer('custom'); + + expect($callbackManager)->toBe($manager) + ->and($callbackContainer)->toBe(app()); +}); + +test('default callbacks take effect after drivers are forgotten', function () { + expect($this->sanitizers->sanitize('

Hello

')) + ->toBe('

Hello

'); + + $this->sanitizers->defaults( + static fn (HtmlSanitizerConfig $config) => $config->allowAttribute('data-test', ['p']), + ); + + expect($this->sanitizers->sanitize('

Hello

')) + ->toBe('

Hello

'); + + $this->sanitizers->forgetDrivers(); + + expect($this->sanitizers->sanitize('

Hello

')) + ->toBe('

Hello

'); +}); + +test('the default sanitizer can be replaced', function () { + $replacement = new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('strong')); + + $this->sanitizers->extend('default', $replacement); + + expect($this->sanitizers->sanitizer())->toBe($replacement); +}); + +test('invalid definitions are rejected', function () { + $this->sanitizers->extend('invalid', fn () => new stdClass); + + expect(fn () => $this->sanitizers->sanitizer('invalid')) + ->toThrow(UnexpectedValueException::class, 'HTML sanitizer [invalid]'); +}); + +test('default callbacks must return a sanitizer config', function () { + $this->sanitizers->defaults(static fn () => null); + + expect(fn () => $this->sanitizers->defaultConfig()) + ->toThrow(UnexpectedValueException::class, HtmlSanitizerConfig::class); +}); + +test('names, has, and all expose sanitizers in registration order', function () { + $resolved = 0; + $first = new HtmlSanitizer(new HtmlSanitizerConfig); + $firstReplacement = new HtmlSanitizer(new HtmlSanitizerConfig); + $second = new HtmlSanitizer(new HtmlSanitizerConfig); + + $this->sanitizers + ->extend('first', function () use (&$resolved, $first) { + $resolved++; + + return $first; + }) + ->extend('second', function () use (&$resolved, $second) { + $resolved++; + + return $second; + }) + ->extend('first', function () use (&$resolved, $firstReplacement) { + $resolved++; + + return $firstReplacement; + }); + + expect($this->sanitizers->has('default'))->toBeTrue() + ->and($this->sanitizers->has('first'))->toBeTrue() + ->and($this->sanitizers->has('missing'))->toBeFalse() + ->and($this->sanitizers->names())->toBe(['default', 'first', 'second']) + ->and($resolved)->toBe(0); + + $sanitizers = $this->sanitizers->all(); + + expect($sanitizers) + ->keys()->all()->toBe(['default', 'first', 'second']) + ->and($sanitizers->get('first'))->toBe($firstReplacement) + ->and($sanitizers->get('second'))->toBe($second) + ->and($resolved)->toBe(2); +}); + +test('sanitizer instances can be used directly', function () { + $sanitizer = new HtmlSanitizer((new HtmlSanitizerConfig)->allowElement('p', ['class'])); + + expect($this->sanitizers->sanitize('

Hello

', $sanitizer)) + ->toBe('

Hello

'); +}); + +test('config repository definitions are registered when the application boots', function () { + $application = new Application(base_path()); + $application->instance('config', new Repository([ + 'app' => ['env' => 'testing'], + 'craft' => [ + 'sanitizers' => [ + 'default' => ['allow_elements' => ['strong' => []]], + 'paragraphs' => ['allow_elements' => ['p' => []]], + ], + ], + ])); + + $application->register(ConfigServiceProvider::class); + $application->boot(); + + $sanitizers = $application->make(HtmlSanitizerManager::class); + + expect($sanitizers->sanitize('Default

Removed

')) + ->toBe('Default') + ->and($sanitizers->sanitize('

Named

Removed', 'paragraphs')) + ->toBe('

Named

'); +}); + +test('unknown sanitizers use the Laravel manager exception', function () { + $this->sanitizers->sanitizer('missing'); +})->throws(InvalidArgumentException::class, 'Driver [missing] not supported.'); diff --git a/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php b/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php deleted file mode 100644 index b278f3c6814..00000000000 --- a/tests/Unit/Support/HtmlSanitizer/HtmlSanitizersTest.php +++ /dev/null @@ -1,108 +0,0 @@ -sanitizers = app(HtmlSanitizers::class); -}); - -afterEach(function () { - File::deleteDirectory(config_path('craft/sanitizers')); -}); - -test('default sanitizer removes unknown attributes and keeps craft additions', function () { - $sanitized = $this->sanitizers->sanitize('
Link'); - - expect($sanitized)->toMatchSnapshot(); -}); - -test('registered sanitizer names can be used', function () { - $this->sanitizers->register('links-only', new HtmlSanitizer((new HtmlSanitizerConfig) - ->allowElement('a') - ->allowAttribute('href', ['a']) - )); - - $sanitized = $this->sanitizers->sanitize('Craftbad', 'links-only'); - - expect($sanitized)->toMatchSnapshot(); -}); - -test('all returns registered sanitizers', function () { - $linksOnlySanitizer = new HtmlSanitizer((new HtmlSanitizerConfig) - ->allowElement('a') - ->allowAttribute('href', ['a']) - ); - - $this->sanitizers->register('links-only', $linksOnlySanitizer); - - $sanitizers = $this->sanitizers->all(); - - expect($sanitizers) - ->toBeInstanceOf(Collection::class) - ->toHaveKeys(['default', 'links-only']) - ->and($sanitizers->get('default'))->toBeInstanceOf(HtmlSanitizerInterface::class) - ->and($sanitizers->get('links-only'))->toBe($linksOnlySanitizer); -}); - -test('default config can be customized with a callback', function () { - $this->sanitizers->defaults(fn (HtmlSanitizerConfig $config) => $config->allowAttribute('class', ['p'])); - - $sanitized = $this->sanitizers->sanitize('

Hello

'); - - expect($sanitized)->toMatchSnapshot(); -}); - -test('default config can be reused for custom sanitizers', function () { - $config = $this->sanitizers->defaultConfig() - ->allowElement('iframe') - ->allowAttribute('src', ['iframe']); - - $this->sanitizers->register('custom', new HtmlSanitizer($config)); - - $sanitized = $this->sanitizers->sanitize('', 'custom'); - - expect($sanitized)->toMatchSnapshot(); -}); - -test('sanitizer instances can be used directly', function () { - $sanitizer = new HtmlSanitizer((new HtmlSanitizerConfig) - ->allowElement('p') - ->allowAttribute('class', ['p'])); - - $sanitized = $this->sanitizers->sanitize('

Hello

', $sanitizer); - - expect($sanitized)->toMatchSnapshot(); -}); - -test('array config files register named sanitizers', function () { - File::ensureDirectoryExists(config_path('craft/sanitizers')); - File::put(config_path('craft/sanitizers/no-headings.php'), <<<'PHP' - true, - 'block_elements' => ['h1'], -]; -PHP); - - app()->forgetInstance(HtmlSanitizers::class); - app(ConfigServiceProvider::class, ['app' => app()])->boot(); - - $sanitized = app(HtmlSanitizers::class)->sanitize('

Title

Body

', 'no-headings'); - - expect($sanitized)->toBe('Title

Body

'); -}); - -test('facade resolves the sanitizer service', function () { - expect(HtmlSanitizersFacade::sanitizer())->toBeInstanceOf(HtmlSanitizerInterface::class); - expect(HtmlSanitizersFacade::defaultConfig())->toBeInstanceOf(HtmlSanitizerConfig::class); -}); diff --git a/tests/Unit/Twig/Extensions/HtmlTwigExtensionTest.php b/tests/Unit/Twig/Extensions/HtmlTwigExtensionTest.php index 98cb34c5bb1..88d7daea1b7 100644 --- a/tests/Unit/Twig/Extensions/HtmlTwigExtensionTest.php +++ b/tests/Unit/Twig/Extensions/HtmlTwigExtensionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Twig\Extensions\HtmlTwigExtension; use CraftCms\Cms\Twig\Twig; use Symfony\Component\HtmlSanitizer\HtmlSanitizer; @@ -74,7 +74,7 @@ function htmlFunctionNames(array $functions): array }); it('sanitizes html with a registered sanitizer name', function () { - app(HtmlSanitizers::class)->register('links-only', new HtmlSanitizer((new HtmlSanitizerConfig) + HtmlSanitizers::extend('links-only', new HtmlSanitizer((new HtmlSanitizerConfig) ->allowElement('a') ->allowAttribute('href', ['a']) )); diff --git a/yii2-adapter/legacy/helpers/HtmlPurifier.php b/yii2-adapter/legacy/helpers/HtmlPurifier.php index bc93c6df1f8..1456fb4a102 100644 --- a/yii2-adapter/legacy/helpers/HtmlPurifier.php +++ b/yii2-adapter/legacy/helpers/HtmlPurifier.php @@ -11,7 +11,7 @@ use craft\htmlpurifier\RelAttrLinkTypeDef; use craft\htmlpurifier\VideoEmbedUrlDef; use CraftCms\Cms\Support\Facades\Deprecator; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\Facades\HtmlSanitizers; use CraftCms\Cms\Support\Str; use CraftCms\Yii2Adapter\HtmlPurifier\HtmlPurifierSanitizer; use HTMLPurifier_Config; @@ -32,7 +32,7 @@ class HtmlPurifier extends \yii\helpers\HtmlPurifier */ public static function process($content, $config = null) { - Deprecator::log('craft\\helpers\\HtmlPurifier::process', 'Calling `craft\\helpers\\HtmlPurifier::process()` is deprecated. Register an HTML sanitizer on `CraftCms\\Cms\\Support\\HtmlSanitizer\\HtmlSanitizers` instead.'); + Deprecator::log('craft\\helpers\\HtmlPurifier::process', 'Calling `craft\\helpers\\HtmlPurifier::process()` is deprecated. Register an HTML sanitizer with `CraftCms\\Cms\\Support\\Facades\\HtmlSanitizers::extend()` instead.'); if ($config instanceof Closure) { return parent::process($content, $config); @@ -46,7 +46,7 @@ public static function process($content, $config = null) return parent::process($content, $config); } - return app(HtmlSanitizers::class)->sanitize($content, new HtmlPurifierSanitizer($config)); + return HtmlSanitizers::sanitize($content, new HtmlPurifierSanitizer($config)); } /** diff --git a/yii2-adapter/src/HtmlPurifier/LegacyHtmlPurifierConfigRegistrar.php b/yii2-adapter/src/HtmlPurifier/LegacyHtmlPurifierConfigRegistrar.php index 7a680cbc34c..a56f055c0f8 100644 --- a/yii2-adapter/src/HtmlPurifier/LegacyHtmlPurifierConfigRegistrar.php +++ b/yii2-adapter/src/HtmlPurifier/LegacyHtmlPurifierConfigRegistrar.php @@ -5,7 +5,7 @@ namespace CraftCms\Yii2Adapter\HtmlPurifier; use CraftCms\Cms\Support\Facades\Deprecator; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Facades\File; @@ -15,7 +15,7 @@ readonly class LegacyHtmlPurifierConfigRegistrar { public function __construct( - private HtmlSanitizers $sanitizers, + private HtmlSanitizerManager $sanitizers, ) { } @@ -56,9 +56,9 @@ public function boot(): void continue; } - $this->sanitizers->register($name, new HtmlPurifierSanitizer($legacyConfig)); + $this->sanitizers->extend($name, new HtmlPurifierSanitizer($legacyConfig)); - $message = "HTML Purifier config file [craft/htmlpurifier/$name.json] is deprecated. Register this sanitizer on the HtmlSanitizers service instead."; + $message = "HTML Purifier config file [craft/htmlpurifier/$name.json] is deprecated. Register this sanitizer on the HtmlSanitizers facade instead."; Deprecator::log( "htmlpurifier-config:$name", diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index 6b07a9ca505..b0570ccc298 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -237,7 +237,7 @@ public function boot(): void new I18NCompatibility()->boot(); new TestToEmailAddressCompatibility()->boot(); - app(LegacyHtmlPurifierConfigRegistrar::class)->boot(); + $this->app->make(LegacyHtmlPurifierConfigRegistrar::class)->boot(); /** * Load legacy Craft diff --git a/yii2-adapter/tests-laravel/HtmlPurifier/LegacyHtmlPurifierConfigRegistrarTest.php b/yii2-adapter/tests-laravel/HtmlPurifier/LegacyHtmlPurifierConfigRegistrarTest.php index 854d887deca..84d97aaef36 100644 --- a/yii2-adapter/tests-laravel/HtmlPurifier/LegacyHtmlPurifierConfigRegistrarTest.php +++ b/yii2-adapter/tests-laravel/HtmlPurifier/LegacyHtmlPurifierConfigRegistrarTest.php @@ -5,7 +5,7 @@ namespace CraftCms\Yii2Adapter\Tests\HtmlPurifier; use CraftCms\Cms\Support\File; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizers; +use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use CraftCms\Cms\Support\Json; use CraftCms\Yii2Adapter\HtmlPurifier\HtmlPurifierSanitizer; use CraftCms\Yii2Adapter\HtmlPurifier\LegacyHtmlPurifierConfigRegistrar; @@ -31,16 +31,14 @@ public function test_register_imports_legacy_json_config(): void app(LegacyHtmlPurifierConfigRegistrar::class)->boot(); - self::assertTrue(app(HtmlSanitizers::class)->has('test')); - - $sanitized = app(HtmlSanitizers::class)->sanitize('Hello', 'test'); + $sanitized = app(HtmlSanitizerManager::class)->sanitize('Hello', 'test'); self::assertSame('Hello', $sanitized); } public function test_html_purifier_sanitizer_can_be_used_directly(): void { - $sanitized = app(HtmlSanitizers::class)->sanitize('

Hello

', new HtmlPurifierSanitizer([ + $sanitized = app(HtmlSanitizerManager::class)->sanitize('

Hello

', new HtmlPurifierSanitizer([ 'Attr.EnableID' => true, ]));