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 array + */ + public function definition(): array + { + $name = $this->faker->slug(2).'.pdf'; + + return [ + 'press_release_id' => PressRelease::factory(), + 'disk' => 'public', + 'path' => 'press-release-attachments/dummy/'.$name, + 'original_name' => $name, + 'mime' => 'application/pdf', + 'size' => $this->faker->numberBetween(50_000, 5_000_000), + 'title' => $this->faker->optional()->sentence(3), + 'description' => $this->faker->optional()->sentence(), + 'sort_order' => 0, + ]; + } +} diff --git a/database/migrations/2026_05_20_143424_add_boilerplate_to_companies.php b/database/migrations/2026_05_20_143424_add_boilerplate_to_companies.php new file mode 100644 index 0000000..f997ea3 --- /dev/null +++ b/database/migrations/2026_05_20_143424_add_boilerplate_to_companies.php @@ -0,0 +1,22 @@ +text('boilerplate')->nullable()->after('website'); + }); + } + + public function down(): void + { + Schema::table('companies', function (Blueprint $table) { + $table->dropColumn('boilerplate'); + }); + } +}; diff --git a/database/migrations/2026_05_20_143424_add_phase7_fields_to_press_releases.php b/database/migrations/2026_05_20_143424_add_phase7_fields_to_press_releases.php new file mode 100644 index 0000000..0d3de1c --- /dev/null +++ b/database/migrations/2026_05_20_143424_add_phase7_fields_to_press_releases.php @@ -0,0 +1,30 @@ +string('subtitle', 255)->nullable()->after('title'); + $table->text('boilerplate_override')->nullable()->after('text'); + $table->timestamp('scheduled_at')->nullable()->after('published_at'); + $table->timestamp('embargo_at')->nullable()->after('scheduled_at'); + + $table->index('scheduled_at', 'press_releases_scheduled_at_idx'); + $table->index('embargo_at', 'press_releases_embargo_at_idx'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropIndex('press_releases_scheduled_at_idx'); + $table->dropIndex('press_releases_embargo_at_idx'); + $table->dropColumn(['subtitle', 'boilerplate_override', 'scheduled_at', 'embargo_at']); + }); + } +}; diff --git a/database/migrations/2026_05_20_143424_create_press_release_attachments_table.php b/database/migrations/2026_05_20_143424_create_press_release_attachments_table.php new file mode 100644 index 0000000..95b529a --- /dev/null +++ b/database/migrations/2026_05_20_143424_create_press_release_attachments_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('press_release_id')->constrained()->cascadeOnDelete(); + $table->string('disk', 30)->default('public'); + $table->string('path', 512); + $table->string('original_name', 255); + $table->string('mime', 100)->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->string('title', 120)->nullable(); + $table->string('description', 500)->nullable(); + $table->unsignedInteger('sort_order')->default(0); + $table->enum('legacy_portal', [ + Portal::Presseecho->value, + Portal::Businessportal24->value, + ])->nullable(); + $table->unsignedBigInteger('legacy_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['legacy_portal', 'legacy_id'], 'press_release_attachments_legacy_portal_legacy_id_unique'); + $table->index(['press_release_id', 'sort_order'], 'press_release_attachments_release_sort_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('press_release_attachments'); + } +}; diff --git a/dev/frontend/hub-flux/03-WEITERE-PHASEN.md b/dev/frontend/hub-flux/03-WEITERE-PHASEN.md index 3171b58..0ff92e3 100644 --- a/dev/frontend/hub-flux/03-WEITERE-PHASEN.md +++ b/dev/frontend/hub-flux/03-WEITERE-PHASEN.md @@ -219,6 +219,14 @@ aber: > Die hub-flux-Roadmap ist mit Phase 6 **vollständig** abgeschlossen. > Alle weiteren Themen sind eigene Initiativen. +**🟡 In Planung — Phase 7 (Press-Release-Form-Refactor):** +Mockup `User Neue Mitteilung presseportale.html` wird auf den +Customer-Create/Edit-Flow übertragen. Plan-Doc: +`19-PHASE-7-PRESS-RELEASE-FORM.md`. +Päckchen 7A (Migrations) → 7B (flux:editor + Sanitizer) → +7C (Customer-Create-UI) → 7D (Customer-Edit-UI) → +7E (Anhänge-Manager) → 7F (Scheduling/Embargo, optional). + 1. **Manueller Dark-Mode Smoke-Test**: Im Browser User-Menü → Erscheinung → „Dunkel" und durch die Hauptseiten klicken (Dashboard, Listen, Detail, Security mit QR, Tokens). Erwartung: diff --git a/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md b/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md new file mode 100644 index 0000000..696f950 --- /dev/null +++ b/dev/frontend/hub-flux/19-PHASE-7-PRESS-RELEASE-FORM.md @@ -0,0 +1,240 @@ +# Phase 7 — Press-Release-Form-Refactor + +> Großes Modul-Refactor: das zentrale „Neue Pressemitteilung"-Form +> wird auf das Mockup `User Neue Mitteilung presseportale.html` gehoben. +> Bekommt deshalb eine eigene Phase außerhalb der bisherigen +> `hub-flux`-Roadmap (Phase 0–6 sind dort abgeschlossen). + +**Status**: 🟡 in Planung · **Aufwand**: 2–3 Tage · **Risiko**: mittel +(Datenmodell-Erweiterung, Editor-Format-Migration, Composer-Dependency) + +--- + +## Ausgangslage + +| Datei | Status | +|---|---| +| `resources/views/livewire/customer/press-releases/create.blade.php` | nur 1-Spalter, ``, fehlende Felder | +| `resources/views/livewire/customer/press-releases/edit.blade.php` | gleicher Stand, plus Image-Manager | +| `resources/views/livewire/admin/press-releases/create.blade.php` | Admin-Variante, dünner | +| `resources/views/livewire/admin/press-releases/edit.blade.php` | Admin-Variante | + +Das Mockup verlangt einen 2-Spalter mit eigener +Settings-Sidebar (Status & Submit, Portal, Pressekontakt, Tags, +Veröffentlichung, SEO). Linke Spalte: Firma-Selector, Titel, +Untertitel, Editor, Medien, Anhänge, Boilerplate. + +--- + +## Entscheidungen (vom User abgesegnet) + +| Frage | Entscheidung | +|---|---| +| Scope | **full** — Anhänge-Tabelle + Schema-Vorbereitung für Scheduling/Embargo | +| Portal-Auswahl | **read-only** — Portal kommt immer aus der Firma; UI zeigt nur Badge | +| HTML-Sanitizer | **`mews/purifier`** — explizit approved, wird in 7B installiert | +| Pressekontakt | **genau 1 pro PM**, Single-Select aus Firmen-Kontakten; Datenmodell bleibt n:m-Pivot, Validation erzwingt `count == 1` | +| Admin-Forms | **mitziehen** in 7C+7D, gleiches Layout + Admin-only Felder | +| Default-Kontakt | **erster Firmen-Kontakt alphabetisch** (keine Schema-Änderung) | + +--- + +## Päckchen-Aufteilung + +### 7A — Migrations + Models + +**Scope:** +- `press_releases.subtitle` (string 255, nullable) +- `press_releases.boilerplate_override` (text, nullable) — pro PM überschreibbare Firmen-Boilerplate +- `press_releases.scheduled_at` (timestamp, nullable) — Schema da, UI „bald" +- `press_releases.embargo_at` (timestamp, nullable) — Schema da, UI „bald" +- `companies.boilerplate` (text, nullable) — Firmenprofil-Boilerplate +- Neue Tabelle `press_release_attachments` analog `press_release_images` + (`disk`, `path`, `original_name`, `mime`, `size`, `sort_order`, + `title`, `description`, `legacy_portal`, `legacy_id`, soft-deletes, + timestamps). +- Models: `PressRelease`, `Company`, neues `PressReleaseAttachment` + + Factory + Relationen + Casts. +- Bestehende Tests müssen grün bleiben (alle Felder nullable, + keine Verhaltensänderung). + +**Akzeptanz** +- [ ] Migration up/down sauber +- [ ] `php artisan test --compact` grün (Baseline) +- [ ] `php artisan db:show press_releases / companies / press_release_attachments` zeigt neue Spalten + +### 7B — Editor-Integration + +**Scope:** +- `composer require mews/purifier` (explizite Approval einholen) +- Service `App\Services\PressRelease\PressReleaseHtmlSanitizer` + (Allowlist: `p,br,h2,h3,strong,em,u,ul,ol,li,blockquote,a[href|rel|target]`) +- Create/Edit-Form: `` → `` mit + reduzierter Toolbar: + ``` + toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo" + ``` +- Save-Pfad: `$this->text = $sanitizer->clean($this->text)` +- Display-Pfad (`show.blade.php`, Portal-Detail-Seiten, + `PressReleaseResource`): + - Wenn `text` HTML-Tags enthält → `{!! $clean !!}` (sanitized) + - Wenn nicht (legacy) → `{!! nl2br(e($text)) !!}` + - Helper `PressRelease::renderedText(): HtmlString` +- API: `PressReleaseResource` liefert weiterhin String (HTML), + Doku-Update `_docs/api/v1.yml` und `dev/migration 2026/07-API-MIGRATION.md`. + +**Akzeptanz** +- [ ] `mews/purifier` in `composer.json` +- [ ] Alle bestehenden Plain-Text-PMs werden korrekt angezeigt +- [ ] Neue HTML-PMs werden korrekt sanitized gespeichert +- [ ] Pest-Test: `'; + + $clean = sanitizer()->clean($dirty); + + expect($clean)->toContain('

Hallo

'); + expect($clean)->not->toContain('not->toContain('alert'); + expect($clean)->not->toContain('clean($dirty); + + expect($clean)->not->toContain('onclick'); + expect($clean)->not->toContain('
  • Punkt A
  • Punkt B
  • ' + .'
    Zitat
    ' + .'Link'; + + $clean = sanitizer()->clean($dirty); + + expect($clean)->toContain('

    Headline

    '); + expect($clean)->toContain('fett'); + expect($clean)->toContain('kursiv'); + expect($clean)->toContain('
      '); + expect($clean)->toContain('
    • Punkt A
    • '); + expect($clean)->toContain('
      '); + expect($clean)->toContain('href="https://example.test"'); +}); + +test('clean removes disallowed tags like h1 table img', function () { + $dirty = '

      Big

      x
      '; + + $clean = sanitizer()->clean($dirty); + + expect($clean)->not->toContain('not->toContain('not->toContain('clean($dirty); + + expect($clean)->toContain('rel='); + expect($clean)->toContain('nofollow'); + expect($clean)->toContain('target="_blank"'); +}); + +test('clean returns empty string for null and whitespace input', function () { + expect(sanitizer()->clean(null))->toBe(''); + expect(sanitizer()->clean(' '))->toBe(''); +}); + +test('isHtml detects html content', function () { + expect(sanitizer()->isHtml('

      Hi

      '))->toBeTrue(); + expect(sanitizer()->isHtml('foo'))->toBeTrue(); + expect(sanitizer()->isHtml('Plain text only'))->toBeFalse(); + expect(sanitizer()->isHtml(''))->toBeFalse(); + expect(sanitizer()->isHtml(null))->toBeFalse(); +}); + +test('render wraps legacy plain text into paragraphs and br tags', function () { + $plain = "Zeile eins\nZeile zwei\n\nNeuer Absatz"; + + $html = (string) sanitizer()->render($plain); + + expect($html)->toContain('

      '); + expect($html)->toContain('Zeile einstoContain('Zeile zwei'); + expect($html)->toContain('Neuer Absatz'); +}); + +test('render escapes html-special chars in legacy plain text', function () { + $plain = 'Skript: '; + + $html = (string) sanitizer()->render($plain); + + expect($html)->not->toContain('toContain('<script'); +}); + +test('render returns sanitized html for stored html content', function () { + $stored = '

      Hallo

      '; + + $html = (string) sanitizer()->render($stored); + + expect($html)->toContain('

      Hallo

      '); + expect($html)->not->toContain('plainTextLength('

      Hallo Welt

      '))->toBe(10); + expect(sanitizer()->plainTextLength('

      Eins

      Zwei

      '))->toBe(9); + expect(sanitizer()->plainTextLength(null))->toBe(0); +}); + +test('PressRelease::renderedText uses the sanitizer', function () { + $pr = PressRelease::factory()->create([ + 'text' => '

      Hallo

      ', + ]); + + $rendered = (string) $pr->renderedText(); + + expect($rendered)->toContain('

      Hallo

      '); + expect($rendered)->not->toContain('create([ + 'subtitle' => 'Eine sinnvolle Subline', + 'boilerplate_override' => 'PM-spezifischer Boilerplate-Text.', + 'scheduled_at' => now()->addDay(), + 'embargo_at' => now()->addDays(2), + ]); + + $fresh = $pr->fresh(); + + expect($fresh->subtitle)->toBe('Eine sinnvolle Subline'); + expect($fresh->boilerplate_override)->toBe('PM-spezifischer Boilerplate-Text.'); + expect($fresh->scheduled_at)->not->toBeNull(); + expect($fresh->embargo_at)->not->toBeNull(); +}); + +test('company accepts a boilerplate field', function () { + $company = Company::factory()->create([ + 'boilerplate' => 'Über die Beispiel GmbH: gegründet 1900, …', + ]); + + expect($company->fresh()->boilerplate)->toBe('Über die Beispiel GmbH: gegründet 1900, …'); +}); + +test('press release attachments table works via factory and relation', function () { + $pr = PressRelease::factory()->create(); + + $attachment = PressReleaseAttachment::factory()->create([ + 'press_release_id' => $pr->id, + 'original_name' => 'pressemappe.pdf', + 'mime' => 'application/pdf', + 'size' => 1_234_567, + 'sort_order' => 1, + ]); + + expect($pr->fresh()->attachments)->toHaveCount(1); + expect($pr->fresh()->attachments->first()->original_name)->toBe('pressemappe.pdf'); + expect($attachment->pressRelease->is($pr))->toBeTrue(); +}); + +test('attachment is removed when press release is force-deleted', function () { + $pr = PressRelease::factory()->create(); + PressReleaseAttachment::factory()->for($pr)->create(); + + $pr->forceDelete(); + + expect(PressReleaseAttachment::query()->withTrashed()->count())->toBe(0); +});