presseportale/app/Services/PressRelease/PressReleaseLinkPolicy.php
2026-06-12 15:24:20 +00:00

132 lines
3.9 KiB
PHP

<?php
namespace App\Services\PressRelease;
use DOMDocument;
use DOMElement;
use Illuminate\Support\Str;
/**
* Systemseitige `rel`-Auszeichnung für Links in Pressemitteilungen
* (Decision-Update „Verlinkung & Backlinks", 11.06.2026):
*
* - **Externe Links** (Kundenseite, Landingpage, Social) →
* `rel="sponsored nofollow noopener"` + `target="_blank"`.
* Google-konform: vom Kunden gesetzte Links sind keine redaktionelle
* Empfehlung des Portals.
* - **Portalinterne Links** (Unternehmensprofil/Newsroom auf den eigenen
* Domains, relative Pfade) → **follow** (kein `rel`) — redaktionelle
* interne Verlinkung stärkt die eigene Domain-Architektur.
* - `mailto:`/`tel:` → kein `rel`, kein `target`.
*
* Die Auszeichnung ist bewusst NICHT kundenseitig wählbar: vom Autor
* mitgelieferte `rel`-Attribute werden grundsätzlich überschrieben.
* Die Policy greift beim Rendern — Änderungen wirken damit rückwirkend
* auf alle gespeicherten Inhalte.
*/
class PressReleaseLinkPolicy
{
public function apply(string $html): string
{
if (trim($html) === '' || ! str_contains($html, '<a')) {
return $html;
}
$document = new DOMDocument;
// Wrapper + XML-Prolog: UTF-8 erzwingen und Fragment-Struktur
// erhalten (loadHTML ergänzt sonst html/body selbst).
$loaded = @$document->loadHTML(
'<?xml encoding="utf-8" ?><div id="pr-link-policy-root">'.$html.'</div>',
LIBXML_NOERROR | LIBXML_NOWARNING,
);
if (! $loaded) {
return $html;
}
foreach ($document->getElementsByTagName('a') as $anchor) {
$this->applyToAnchor($anchor);
}
$root = $document->getElementById('pr-link-policy-root');
if (! $root) {
return $html;
}
$result = '';
foreach ($root->childNodes as $child) {
$result .= $document->saveHTML($child);
}
return $result;
}
private function applyToAnchor(DOMElement $anchor): void
{
$href = trim($anchor->getAttribute('href'));
// rel ist systemgesteuert — Autoren-Eingaben zählen nie.
$anchor->removeAttribute('rel');
if ($href === '' || Str::startsWith($href, ['mailto:', 'tel:', '#'])) {
$anchor->removeAttribute('target');
return;
}
if ($this->isInternal($href)) {
$anchor->removeAttribute('target');
return;
}
$anchor->setAttribute('rel', 'sponsored nofollow noopener');
$anchor->setAttribute('target', '_blank');
}
/**
* Intern = relative Pfade sowie absolute URLs auf eine der
* konfigurierten Portal-Domains (inkl. www-Variante).
*/
private function isInternal(string $href): bool
{
if (! preg_match('#^https?://#i', $href)) {
// Relative Pfade (/firma/...) — kein Protokoll, keine Domain.
return ! Str::startsWith($href, '//');
}
$host = strtolower((string) parse_url($href, PHP_URL_HOST));
if ($host === '') {
return false;
}
$internalHosts = $this->internalHosts();
return in_array($host, $internalHosts, true)
|| in_array(Str::after($host, 'www.'), $internalHosts, true);
}
/**
* @return list<string>
*/
private function internalHosts(): array
{
$hosts = [];
foreach ((array) config('domains.domains', []) as $domain) {
foreach ([$domain['domain_name'] ?? null, parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST)] as $candidate) {
if (is_string($candidate) && $candidate !== '') {
$host = strtolower($candidate);
$hosts[] = $host;
$hosts[] = Str::after($host, 'www.');
}
}
}
return array_values(array_unique($hosts));
}
}