diff --git a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php
index 412c3cc..80ed517 100644
--- a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php
+++ b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php
@@ -17,6 +17,8 @@ class PressReleaseHtmlSanitizer
{
private const string PURIFIER_PROFILE = 'press_release';
+ public function __construct(private readonly PressReleaseLinkPolicy $linkPolicy) {}
+
/**
* Sanitize HTML before persisting to the database.
*/
@@ -44,6 +46,10 @@ class PressReleaseHtmlSanitizer
/**
* Produce a display-ready, safe HtmlString.
+ *
+ * Die Link-Policy (rel systemseitig: extern sponsored/nofollow,
+ * portalintern follow) greift hier beim Rendern — so wirken
+ * Regel-Änderungen rückwirkend auf alle gespeicherten Inhalte.
*/
public function render(?string $text): HtmlString
{
@@ -52,7 +58,7 @@ class PressReleaseHtmlSanitizer
}
if ($this->isHtml($text)) {
- return new HtmlString($this->clean($text));
+ return new HtmlString($this->linkPolicy->apply($this->clean($text)));
}
$escaped = e($text);
diff --git a/app/Services/PressRelease/PressReleaseLinkPolicy.php b/app/Services/PressRelease/PressReleaseLinkPolicy.php
new file mode 100644
index 0000000..7cbc812
--- /dev/null
+++ b/app/Services/PressRelease/PressReleaseLinkPolicy.php
@@ -0,0 +1,132 @@
+loadHTML(
+ '
'.$html.'
',
+ 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
+ */
+ 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));
+ }
+}
diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md
index 694c24c..9bab32a 100644
--- a/dev/frontend/hub-flux/PROGRESS.md
+++ b/dev/frontend/hub-flux/PROGRESS.md
@@ -5,6 +5,25 @@
---
+## 2026-06-12 · Verlinkung & Backlinks (Decision-Update 11.06.) ✅
+
+- **Was**: Systemseitige `rel`-Auszeichnung für Links in PMs umgesetzt:
+ neue `PressReleaseLinkPolicy`, angewendet in
+ `PressReleaseHtmlSanitizer::render()` (wirkt rückwirkend auf alle
+ gespeicherten Inhalte). Externe Links → `sponsored nofollow noopener`
+ + `target="_blank"`, portalinterne Links (drei Domains inkl. www,
+ relative Pfade) → follow, mailto/tel ohne rel/target. Autoren-`rel`
+ wird immer überschrieben — kein kundenseitiger follow/nofollow-Hebel.
+ Editor-Hinweis unter der Schreibfläche (Create/Edit). §9-Korrektur im
+ Preisstruktur-Decision-Update eingearbeitet, Umsetzungsstand im
+ Verlinkungs-Dokument ergänzt.
+- **Dateien**: `app/Services/PressRelease/PressReleaseLinkPolicy.php`
+ (neu), `PressReleaseHtmlSanitizer.php`, Editor-Views (Create/Edit),
+ beide Decision-Dokumente.
+- **Build/Test**: 6 neue Policy-Tests in `PressReleaseHtmlSanitizerTest`.
+- **Offene Fragen**: CTA-Box/Darstellungs-Stufung → Boost-Konzept (9I);
+ Link-Obergrenze pro PM und Anchor-Text-Soft-Check → Phase 2.
+
## 2026-06-12 · PM-Vorschau-Umbau + Profil-Feinschliff ✅
- **Was**: (1) PM-Detailseite (Customer) nach Kevins Review umgebaut:
diff --git a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md
index aecd4f7..7c1d0c6 100644
--- a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md
+++ b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md
@@ -98,7 +98,7 @@ Zum Launch greifen genau drei Credit-Posten – alle ohne KI-Abhängigkeit, alle
**Credit-Anker:** 1 Credit = 1 € (Listenpreis), Volumenrabatt über Pakete.
-**Bewusst ausgeschlossen:** Verkaufte Dofollow-Backlinks. Verstoßen gegen Googles Link-Spam-Richtlinien (Presse-Links gehören auf `nofollow`/`sponsored`) und widersprechen der Ehrlichkeits-Positionierung frontal.
+**Verlinkung:** Links zur Kundenseite sind Standard-Bestandteil jeder PM, systemseitig als `sponsored`/`nofollow` ausgezeichnet. Hervorhebung der Linkdarstellung ist als Produkt-Feature möglich. **Tabu:** Verkauf von Dofollow-Backlinks und kundenseitige `rel`-Auswahl. Details siehe Decision-Update „Verlinkung & Backlinks".
---
diff --git a/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md
index 9137bd1..5be5c63 100644
--- a/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md
+++ b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md
@@ -117,4 +117,29 @@ ersetzt durch:
---
-_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._
\ No newline at end of file
+_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._
+---
+
+## 10. Umsetzungsstand (12.06.2026)
+
+**Umgesetzt:**
+
+- `PressReleaseLinkPolicy` (app/Services/PressRelease/): systemseitige
+ `rel`-Auszeichnung beim Rendern — extern → `sponsored nofollow noopener`
+ + `target="_blank"`; portalintern (konfigurierte Domains aus
+ `config/domains.php` inkl. www-Variante, relative Pfade) → **follow**;
+ `mailto:`/`tel:` → ohne `rel`/`target`. Autoren-`rel` wird immer
+ überschrieben (kein kundenseitiger Hebel). Greift in
+ `PressReleaseHtmlSanitizer::render()` und wirkt damit rückwirkend auf
+ alle gespeicherten Inhalte und auf jede Ausgabe (Panel-Vorschau heute,
+ Web-Detailseiten beim Relaunch).
+- Editor-Hinweis (PM anlegen/bearbeiten): Links erwünscht, Auszeichnung
+ automatisch — bewusst ohne follow/nofollow-Auswahl im UI.
+- §9-Korrektur im Decision-Update „Preisstruktur & Veröffentlichungs-Flow"
+ (Abschnitt 4) eingearbeitet.
+
+**Offen (wie in §8 vorgesehen):** CTA-Box/Darstellungs-Stufung (hängt am
+Boost-Konzept, 9I), Link-Obergrenze pro PM, Anchor-Text-Soft-Check
+(Phase 2). Die Linktyp-Auswahl im Editor ist fürs `rel` nicht nötig
+(systemseitig aus der Ziel-URL abgeleitet) — sie wird relevant, sobald die
+CTA-Box als Darstellungs-Option kommt.
diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php
index f95ed37..31d7d46 100644
--- a/resources/views/livewire/customer/press-releases/create.blade.php
+++ b/resources/views/livewire/customer/press-releases/create.blade.php
@@ -892,6 +892,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
/>
+
+ {{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }}
+
+
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php
index 457a7ca..009fcb4 100644
--- a/resources/views/livewire/customer/press-releases/edit.blade.php
+++ b/resources/views/livewire/customer/press-releases/edit.blade.php
@@ -844,6 +844,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
/>
+
+ {{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }}
+
+
diff --git a/tests/Feature/PressReleaseHtmlSanitizerTest.php b/tests/Feature/PressReleaseHtmlSanitizerTest.php
index 4038680..fed58b4 100644
--- a/tests/Feature/PressReleaseHtmlSanitizerTest.php
+++ b/tests/Feature/PressReleaseHtmlSanitizerTest.php
@@ -123,3 +123,49 @@ test('PressRelease::renderedText uses the sanitizer', function () {
expect($rendered)->toContain('Hallo
');
expect($rendered)->not->toContain('