diff --git a/_docs/api/v1.yml b/_docs/api/v1.yml index 59c3af3..69f334c 100644 --- a/_docs/api/v1.yml +++ b/_docs/api/v1.yml @@ -310,6 +310,12 @@ components: maxLength: 255 text: type: string + description: | + Body of the press release. Since Phase 7 (May 2026) this may + contain sanitized HTML produced by the rich-text editor + (allowed tags: p, br, h2, h3, strong, em, ul, ol, li, + blockquote, a). PMs imported before Phase 7 are plain text. + API consumers should treat the value as untrusted HTML. backlink_url: type: string format: uri diff --git a/app/Models/Company.php b/app/Models/Company.php index 37e1d35..be06b5c 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -53,6 +53,7 @@ class Company extends Model 'website', 'logo_path', 'logo_variants', + 'boilerplate', 'is_active', 'disable_footer_code', 'legacy_portal', diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 59dd2c7..26ec88b 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -6,6 +6,7 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Concerns\HasUniqueSlug; use App\Scopes\PortalScope; +use App\Services\PressRelease\PressReleaseHtmlSanitizer; use Database\Factories\PressReleaseFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\HtmlString; class PressRelease extends Model { @@ -45,8 +47,10 @@ class PressRelease extends Model 'category_id', 'language', 'title', + 'subtitle', 'slug', 'text', + 'boilerplate_override', 'backlink_url', 'keywords', 'status', @@ -55,6 +59,8 @@ class PressRelease extends Model 'teaser_end', 'no_export', 'published_at', + 'scheduled_at', + 'embargo_at', 'legacy_portal', 'legacy_id', ]; @@ -69,6 +75,8 @@ class PressRelease extends Model 'teaser_end' => 'integer', 'no_export' => 'boolean', 'published_at' => 'datetime', + 'scheduled_at' => 'datetime', + 'embargo_at' => 'datetime', 'deleted_at' => 'datetime', ]; } @@ -93,6 +101,11 @@ class PressRelease extends Model return $this->hasMany(PressReleaseImage::class); } + public function attachments(): HasMany + { + return $this->hasMany(PressReleaseAttachment::class); + } + public function contacts(): BelongsToMany { return $this->belongsToMany(Contact::class, 'press_release_contact'); @@ -102,4 +115,18 @@ class PressRelease extends Model { return $this->hasMany(PressReleaseStatusLog::class)->orderByDesc('created_at'); } + + /** + * Display-ready text. Returns sanitized HTML for Phase-7+ PMs and + *
/
-wrapped legacy plain text for older imports.
+ */
+ public function renderedText(): HtmlString
+ {
+ return app(PressReleaseHtmlSanitizer::class)->render($this->text);
+ }
+
+ public function plainTextLength(): int
+ {
+ return app(PressReleaseHtmlSanitizer::class)->plainTextLength($this->text);
+ }
}
diff --git a/app/Models/PressReleaseAttachment.php b/app/Models/PressReleaseAttachment.php
new file mode 100644
index 0000000..db1b31d
--- /dev/null
+++ b/app/Models/PressReleaseAttachment.php
@@ -0,0 +1,67 @@
+ */
+ use HasFactory, SoftDeletes;
+
+ protected $fillable = [
+ 'press_release_id',
+ 'disk',
+ 'path',
+ 'original_name',
+ 'mime',
+ 'size',
+ 'title',
+ 'description',
+ 'sort_order',
+ 'legacy_portal',
+ 'legacy_id',
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'size' => 'integer',
+ 'sort_order' => 'integer',
+ 'deleted_at' => 'datetime',
+ ];
+ }
+
+ public function pressRelease(): BelongsTo
+ {
+ return $this->belongsTo(PressRelease::class);
+ }
+
+ public function url(): ?string
+ {
+ if (blank($this->path)) {
+ return null;
+ }
+
+ if ($this->disk === 'public') {
+ return asset('storage/'.ltrim((string) $this->path, '/'));
+ }
+
+ try {
+ $disk = Storage::disk($this->disk);
+
+ if (method_exists($disk, 'url')) {
+ return $disk->url($this->path);
+ }
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php
new file mode 100644
index 0000000..412c3cc
--- /dev/null
+++ b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php
@@ -0,0 +1,85 @@
+/
so it renders consistently next to new HTML content.
+ */
+class PressReleaseHtmlSanitizer
+{
+ private const string PURIFIER_PROFILE = 'press_release';
+
+ /**
+ * Sanitize HTML before persisting to the database.
+ */
+ public function clean(?string $html): string
+ {
+ if ($html === null || trim($html) === '') {
+ return '';
+ }
+
+ return (string) Purifier::clean($html, self::PURIFIER_PROFILE);
+ }
+
+ /**
+ * Detect whether the stored text is already HTML (Phase 7+) or
+ * legacy plain text from older imports.
+ */
+ public function isHtml(?string $text): bool
+ {
+ if ($text === null || $text === '') {
+ return false;
+ }
+
+ return (bool) preg_match('/<(p|br|h2|h3|strong|em|ul|ol|li|blockquote|a)\b[^>]*>/i', $text);
+ }
+
+ /**
+ * Produce a display-ready, safe HtmlString.
+ */
+ public function render(?string $text): HtmlString
+ {
+ if ($text === null || trim($text) === '') {
+ return new HtmlString('');
+ }
+
+ if ($this->isHtml($text)) {
+ return new HtmlString($this->clean($text));
+ }
+
+ $escaped = e($text);
+ $withBreaks = nl2br($escaped, false);
+ $paragraphs = preg_split('/(?:
\s*){2,}/i', $withBreaks) ?: [$withBreaks];
+
+ $html = collect($paragraphs)
+ ->map(fn (string $chunk): string => trim($chunk))
+ ->filter()
+ ->map(fn (string $chunk): string => '
'.$chunk.'
') + ->implode(''); + + return new HtmlString($html); + } + + /** + * Plain-text length for character counters (without HTML noise). + */ + public function plainTextLength(?string $text): int + { + if ($text === null || $text === '') { + return 0; + } + + $stripped = strip_tags($text); + $decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return mb_strlen(trim((string) preg_replace('/\s+/u', ' ', $decoded))); + } +} diff --git a/composer.json b/composer.json index 7d75818..89b7807 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "livewire/flux": "^2.1.1", "livewire/flux-pro": "^2.1", "livewire/volt": "^1.7.0", + "mews/purifier": "^3.4", "spatie/laravel-permission": "^6.17" }, "require-dev": { diff --git a/composer.lock b/composer.lock index c2b59c2..6db2f41 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7e9069d62aef1b99ea677b768afeda3f", + "content-hash": "cbc29fc1cf64ca319c7c0ef7e0c1088c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -763,6 +763,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -2687,6 +2748,84 @@ }, "time": "2026-03-18T14:16:30+00:00" }, + { + "name": "mews/purifier", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/mewebstudio/Purifier.git", + "reference": "b2705cc6c832ce7229373418e191d71b6c037841" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mewebstudio/Purifier/zipball/b2705cc6c832ce7229373418e191d71b6c037841", + "reference": "b2705cc6c832ce7229373418e191d71b6c037841", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.16.0", + "illuminate/config": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^5.8|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^7.2|^8.0" + }, + "require-dev": { + "graham-campbell/testbench": "^3.2|^5.5.1|^6.1", + "mockery/mockery": "^1.3.3", + "phpunit/phpunit": "^8.0|^9.0|^10.0" + }, + "suggest": { + "laravel/framework": "To test the Laravel bindings", + "laravel/lumen-framework": "To test the Lumen bindings" + }, + "type": "package", + "extra": { + "laravel": { + "aliases": { + "Purifier": "Mews\\Purifier\\Facades\\Purifier" + }, + "providers": [ + "Mews\\Purifier\\PurifierServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Mews\\Purifier\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Muharrem ERİN", + "email": "me@mewebstudio.com", + "homepage": "https://github.com/mewebstudio", + "role": "Developer" + } + ], + "description": "Laravel 5/6/7/8/9/10 HtmlPurifier Package", + "homepage": "https://github.com/mewebstudio/purifier", + "keywords": [ + "Laravel Purifier", + "Laravel Security", + "Purifier", + "htmlpurifier", + "laravel HtmlPurifier", + "security", + "xss" + ], + "support": { + "issues": "https://github.com/mewebstudio/Purifier/issues", + "source": "https://github.com/mewebstudio/Purifier/tree/3.4.4" + }, + "time": "2026-04-15T16:41:08+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/config/purifier.php b/config/purifier.php new file mode 100644 index 0000000..81148a6 --- /dev/null +++ b/config/purifier.php @@ -0,0 +1,127 @@ +set('Core.Encoding', $this->config->get('purifier.encoding')); + * $config->set('Cache.SerializerPath', $this->config->get('purifier.cachePath')); + * if ( ! $this->config->get('purifier.finalize')) { + * $config->autoFinalize = false; + * } + * $config->loadArray($this->getConfig()); + * + * You must NOT delete the default settings + * anything in settings should be compacted with params that needed to instance HTMLPurifier_Config. + * + * @link http://htmlpurifier.org/live/configdoc/plain.html + */ + +return [ + 'encoding' => 'UTF-8', + 'finalize' => true, + 'ignoreNonStrings' => false, + 'cachePath' => storage_path('app/purifier'), + 'cacheFileMode' => 0755, + 'settings' => [ + 'default' => [ + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'div,b,strong,i,em,u,a[href|title],ul,ol,li,p[style],br,span[style],img[width|height|alt|src]', + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + 'AutoFormat.AutoParagraph' => true, + 'AutoFormat.RemoveEmpty' => true, + ], + /* + * Press-Release-Editor (Phase 7). + * Tight allowlist matching the reduced flux:editor toolbar: + * heading (h2,h3) | bold italic | bullet ordered blockquote | link. + * Links open in a new tab with safe rel attributes. + */ + 'press_release' => [ + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'p,br,h2,h3,strong,em,ul,ol,li,blockquote,a[href|title|target|rel]', + 'HTML.TargetBlank' => true, + 'HTML.Nofollow' => true, + 'AutoFormat.AutoParagraph' => true, + 'AutoFormat.RemoveEmpty' => true, + 'URI.AllowedSchemes' => [ + 'http' => true, + 'https' => true, + 'mailto' => true, + 'tel' => true, + ], + ], + 'test' => [ + 'Attr.EnableID' => 'true', + ], + 'youtube' => [ + 'HTML.SafeIframe' => 'true', + 'URI.SafeIframeRegexp' => '%^(http://|https://|//)(www.youtube.com/embed/|player.vimeo.com/video/)%', + ], + 'custom_definition' => [ + 'id' => 'html5-definitions', + 'rev' => 1, + 'debug' => false, + 'elements' => [ + // http://developers.whatwg.org/sections.html + ['section', 'Block', 'Flow', 'Common'], + ['nav', 'Block', 'Flow', 'Common'], + ['article', 'Block', 'Flow', 'Common'], + ['aside', 'Block', 'Flow', 'Common'], + ['header', 'Block', 'Flow', 'Common'], + ['footer', 'Block', 'Flow', 'Common'], + + // Content model actually excludes several tags, not modelled here + ['address', 'Block', 'Flow', 'Common'], + ['hgroup', 'Block', 'Required: h1 | h2 | h3 | h4 | h5 | h6', 'Common'], + + // http://developers.whatwg.org/grouping-content.html + ['figure', 'Block', 'Optional: (figcaption, Flow) | (Flow, figcaption) | Flow', 'Common'], + ['figcaption', 'Inline', 'Flow', 'Common'], + + // http://developers.whatwg.org/the-video-element.html#the-video-element + ['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', 'Block', 'Flow', 'Common', [ + 'src' => 'URI', + 'type' => 'Text', + ]], + + // http://developers.whatwg.org/text-level-semantics.html + ['s', 'Inline', 'Inline', 'Common'], + ['var', 'Inline', 'Inline', 'Common'], + ['sub', 'Inline', 'Inline', 'Common'], + ['sup', 'Inline', 'Inline', 'Common'], + ['mark', 'Inline', 'Inline', 'Common'], + ['wbr', 'Inline', 'Empty', 'Core'], + + // http://developers.whatwg.org/edits.html + ['ins', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']], + ['del', 'Block', 'Flow', 'Common', ['cite' => 'URI', 'datetime' => 'CDATA']], + ], + 'attributes' => [ + ['iframe', 'allowfullscreen', 'Bool'], + ['table', 'height', 'Text'], + ['td', 'border', 'Text'], + ['th', 'border', 'Text'], + ['tr', 'width', 'Text'], + ['tr', 'height', 'Text'], + ['tr', 'border', 'Text'], + ], + ], + 'custom_attributes' => [ + ['a', 'target', 'Enum#_blank,_self,_target,_top'], + ], + 'custom_elements' => [ + ['u', 'Inline', 'Inline', 'Common'], + ], + ], + +]; diff --git a/database/factories/PressReleaseAttachmentFactory.php b/database/factories/PressReleaseAttachmentFactory.php new file mode 100644 index 0000000..1330046 --- /dev/null +++ b/database/factories/PressReleaseAttachmentFactory.php @@ -0,0 +1,33 @@ + + */ +class PressReleaseAttachmentFactory extends Factory +{ + /** + * @return arrayHallo
'); + expect($clean)->not->toContain(''; + + $html = (string) sanitizer()->render($plain); + + expect($html)->not->toContain(''; + + $html = (string) sanitizer()->render($stored); + + expect($html)->toContain('Hallo
'); + expect($html)->not->toContain('', + ]); + + $rendered = (string) $pr->renderedText(); + + expect($rendered)->toContain('Hallo
'); + expect($rendered)->not->toContain('