create PM v0.5
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
Kevin Adametz 2026-05-20 19:14:39 +02:00
parent 9b47296cea
commit d2ba22c0cf
25 changed files with 2155 additions and 72 deletions

View file

@ -53,6 +53,7 @@ class Company extends Model
'website',
'logo_path',
'logo_variants',
'boilerplate',
'is_active',
'disable_footer_code',
'legacy_portal',

View file

@ -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
* <p>/<br>-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);
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Database\Factories\PressReleaseAttachmentFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class PressReleaseAttachment extends Model
{
/** @use HasFactory<PressReleaseAttachmentFactory> */
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;
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace App\Services\PressRelease;
use Illuminate\Support\HtmlString;
use Mews\Purifier\Facades\Purifier;
/**
* Sanitizes press-release HTML coming out of the Flux/Tiptap editor.
*
* - On save: clean() strips everything outside the press_release allowlist.
* - On display: render() returns a safe HtmlString. Legacy plain-text content
* (PMs imported before Phase 7) is detected heuristically and wrapped in
* <p>/<br> 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('/(?:<br\s*\/?>\s*){2,}/i', $withBreaks) ?: [$withBreaks];
$html = collect($paragraphs)
->map(fn (string $chunk): string => trim($chunk))
->filter()
->map(fn (string $chunk): string => '<p>'.$chunk.'</p>')
->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)));
}
}