diff --git a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php index 9c9149f7..8dd01f56 100644 --- a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php +++ b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php @@ -2,33 +2,82 @@ namespace App\Http\Middleware; +use App\Models\Company; +use App\Models\PressRelease; use App\Support\StaticPageCanonical; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * Portalseiten auf dem Hub (Anhang 2, E10): Preise, Veröffentlichen, - * Newsrooms und Suche gibt es nur auf den Portalen. Unter pressekonto.com - * wurden sie bisher trotzdem ausgeliefert — mit dem Branding von - * businessportal24, weil der Portal-Kontext dort darauf zurückfällt. Statt - * einer Seite mit fremdem Namen unter fremder Domain: 301 auf das führende - * Portal. + * Portalseiten und Portal-Inhalte auf dem Hub (Anhang 2, E10). + * + * Die Ausgabe-Routen (`/{edition}/…`) gelten für alle Hosts, deshalb waren + * Preise, Veröffentlichen, Newsrooms, Suche, Kategorien, Jahresarchive, + * Meldungen und Newsrooms auch unter pressekonto.com erreichbar — mit dem + * Branding von businessportal24, weil der Portal-Kontext auf dem Hub darauf + * zurückfällt. Der Hub ist der Publisher-Bereich und hat diese Inhalte + * nicht; sie gehören auf das Portal. + * + * Ziel ist bei Meldung und Newsroom das Portal des Eintrags selbst (bei + * `both` und bei den übrigen Seiten das Standard-Portal). Ist der Eintrag + * unbekannt, wird NICHT weitergeleitet — dann läuft wie bisher die 404- + * bzw. Tombstone-Behandlung, statt den Besucher auf eine Adresse zu + * schicken, die es auch dort nicht gibt. */ class RedirectPortalOnlyPagesFromHub { public function handle(Request $request, Closure $next): Response { - if (! str_contains($request->getHost(), 'pressekonto')) { + // Nur lesende Aufrufe: ein 301 auf ein POST (Newsroom beanspruchen, + // Share-Zählung) würde die Anfrage verlieren. + if (! str_contains($request->getHost(), 'pressekonto') || ! $request->isMethod('GET')) { return $next($request); } - $base = StaticPageCanonical::portalOnlyRedirect($request->route()?->getName()); + $routeName = $request->route()?->getName(); + $base = StaticPageCanonical::portalOnlyRedirect($routeName) + ?? $this->contentPortalBase($request, $routeName); if ($base === null) { return $next($request); } - return redirect($base.$request->getPathInfo(), 301); + return redirect($base.$request->getRequestUri(), 301); + } + + /** + * Portal-Basis für eine Inhaltsadresse, oder null, wenn die Route keine + * Inhaltsroute ist oder der Eintrag nicht existiert. + */ + private function contentPortalBase(Request $request, ?string $routeName): ?string + { + if ($routeName === null || ! in_array($routeName, (array) config('canonical.portal_only_content_routes', []), true)) { + return null; + } + + $slug = $request->route()?->parameter('slug'); + + // Kategorie und Jahresarchiv gibt es auf beiden Portalen — dort + // entscheidet das Standard-Portal. + if (! is_string($slug) || ! in_array($routeName, ['release.detail', 'newsroom', 'newsroom.claim'], true)) { + return StaticPageCanonical::defaultPortalBase(); + } + + // Entfernte und depublizierte Einträge zählen mit: ihre Adresse + // liefert auf dem Portal einen Tombstone, und der gehört dorthin. + // + // Bei Doppelveröffentlichung tragen beide Fassungen denselben Slug — + // dann gewinnt die Erstquelle (ohne `canonical_press_release_id`), + // damit die Weiterleitung nicht auf der Kopie endet. + $portal = $routeName === 'release.detail' + ? PressRelease::query()->withoutGlobalScopes() + ->where('slug', $slug) + ->orderByRaw('canonical_press_release_id is null desc') + ->orderBy('id') + ->value('portal') + : Company::query()->withoutGlobalScopes()->where('slug', $slug)->orderBy('id')->value('portal'); + + return $portal === null ? null : StaticPageCanonical::portalBaseFor((string) ($portal->value ?? $portal)); } } diff --git a/app/Support/StaticPageCanonical.php b/app/Support/StaticPageCanonical.php index 3b7928f6..6314e348 100644 --- a/app/Support/StaticPageCanonical.php +++ b/app/Support/StaticPageCanonical.php @@ -2,6 +2,8 @@ namespace App\Support; +use App\Enums\Portal; + /** * Kanonischer Host statischer Seiten (Roadmap Anhang 2, E10). * @@ -109,8 +111,32 @@ class StaticPageCanonical return null; } + return self::defaultPortalBase(); + } + + /** + * Basis-URL des Standard-Portals (config canonical.portal_only_host). + */ + public static function defaultPortalBase(): ?string + { $url = config('domains.domains.'.config('canonical.portal_only_host', 'presseecho').'.url'); return is_string($url) && $url !== '' ? rtrim($url, '/') : null; } + + /** + * Basis-URL eines Portals aus dem Enum-Wert (`presseecho`, + * `businessportal24`); `both` und Unbekanntes fallen auf das + * Standard-Portal zurück. + */ + public static function portalBaseFor(?string $portal): ?string + { + if ($portal === null || $portal === Portal::Both->value) { + return self::defaultPortalBase(); + } + + $url = config('domains.domains.'.$portal.'.url'); + + return is_string($url) && $url !== '' ? rtrim($url, '/') : self::defaultPortalBase(); + } } diff --git a/config/canonical.php b/config/canonical.php index c5016ee7..22d23c40 100644 --- a/config/canonical.php +++ b/config/canonical.php @@ -84,6 +84,17 @@ return [ 'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche'], + /* + | Portal-INHALTE (Kategorie, Jahresarchiv, Meldung, Newsroom). Die + | Ausgabe-Routen gelten für alle Hosts, deshalb waren sie auch unter + | pressekonto.com erreichbar — dieselben Texte ein drittes Mal, nur mit + | dem Branding des Portals, auf das der Hub-Kontext zurückfällt. + | Ziel ist bei Meldung und Newsroom das Portal des Eintrags selbst, + | sonst `portal_only_host`. + */ + + 'portal_only_content_routes' => ['kategorie', 'kategorie.archiv', 'release.detail', 'newsroom', 'newsroom.claim'], + 'portal_only_host' => 'presseecho', ]; diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index bb8fa2b0..a91695c6 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -2057,18 +2057,25 @@ Alle 24 Hub-Adressen und jeder Link der Hub-Startseite geprüft: **alles 2. **Weiterleitungskette** (behoben): `/de/kontakt` und `/de/ueber-uns` zeigten auf `/de`, das auf dem Hub selbst auf `/` weiterleitet — zwei Sprünge. Jetzt direkt auf die Startseite des jeweiligen Hosts. -3. **Portal-Inhalte unter pressekonto.com** (offen, Empfehlung unten): - `/de/category/{slug}`, `/de/category/{slug}/archiv/{jahr}` und - `/de/press-release/{slug}` sind auf dem Hub erreichbar und rendern das - Portal (businessportal24), weil die Ausgabe-Routen für alle Hosts - gelten. Ihr Canonical zeigt auf das Portal — sie stehen in keiner - Sitemap und werden nicht zusätzlich indexiert. **Empfehlung:** auch - diese Routen auf dem Hub per 301 auf businessportal24 legen (dasselbe - Portal, das der Hub ohnehin rendert; presseecho-only-Meldungen laufen - heute wie danach ins 404). Ein Eintrag in - `canonical.portal_only_pages` würde nicht reichen, weil das Ziel dort - fest auf presseecho steht — braucht eine eigene Zeile. Nicht ohne - Freigabe gemacht, weil es rund 34.000 Adressen betrifft. +3. **Portal-Inhalte unter pressekonto.com** (behoben am 18.09.2026 nach + Freigabe): `/de/category/{slug}`, `/de/category/{slug}/archiv/{jahr}`, + `/de/press-release/{slug}`, `/de/newsroom/{slug}` und + `/de/newsroom/{slug}/verwalten` waren auf dem Hub erreichbar und + renderten das Portal, weil die Ausgabe-Routen für alle Hosts gelten. + Jetzt 301 (`canonical.portal_only_content_routes`): + + - **Meldung und Newsroom folgen ihrem eigenen Portal**, nicht einer + festen Vorgabe. Bei Doppelveröffentlichung tragen beide Fassungen + denselben Slug — dann gewinnt die Erstquelle + (`canonical_press_release_id is null`), sonst landete die + Weiterleitung auf der Kopie. + - Kategorie, Jahresarchiv und Suche gibt es auf beiden Portalen: dort + entscheidet `portal_only_host` (presseecho). + - **Unbekannter Slug wird nicht weitergeleitet** — sonst entstünde eine + 301-Kette in ein 404 auf dem Portal. Der Hub antwortet wie bisher 404. + - **Nur GET.** Ein 301 auf POST würde die Anfrage verlieren; Newsroom + beanspruchen und Share-Zählung laufen weiter. + - Query-Parameter bleiben erhalten (`/de/suche?q=…`). Technischer Nebeneffekt: Der Self-Canonical kommt jetzt aus dem tatsächlich aufgerufenen Host (`StaticPageCanonical::selfBase()`) statt aus diff --git a/tests/Feature/Web/HubPortalPageRedirectTest.php b/tests/Feature/Web/HubPortalPageRedirectTest.php index 3c3d0940..d058da79 100644 --- a/tests/Feature/Web/HubPortalPageRedirectTest.php +++ b/tests/Feature/Web/HubPortalPageRedirectTest.php @@ -2,6 +2,9 @@ use App\Enums\Portal; use App\Models\Category; +use App\Models\Company; +use App\Models\PressRelease; +use App\Services\PressRelease\CanonicalAssigner; use Tests\TestCase; test('portal-only pages redirect from the hub to the leading portal', function (string $path) { @@ -60,15 +63,113 @@ test('the retired contact and about pages redirect to the home page in one hop', ['https://presseecho.test', 'https://presseecho.test/de'], ]); -test('portal content served through the hub keeps the portal as canonical', function () { +test('portal content keeps its own portal as canonical', function () { /** @var TestCase $this */ - // Die Ausgabe-Routen gelten für alle Hosts, deshalb sind Kategorien und - // Meldungen auch unter pressekonto.com erreichbar. Sie rendern dort das - // Portal — der Canonical muss dorthin zeigen, nie auf den Hub. + // Sicherheitsnetz hinter der Weiterleitung: Portal-Inhalte, die über den + // Hub-Kontext gerendert werden, dürfen niemals pressekonto als Canonical + // tragen (`webPortalContextApplied`). $category = Category::factory()->create(['portal' => Portal::Both]); $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); - $this->get('https://pressekonto.test/de/category/handel')->assertSuccessful() - ->assertSee('', false) - ->assertDontSee('', false); + $this->get('https://businessportal24.test/de/category/handel')->assertSuccessful() + ->assertSee('', false); + $this->get('https://presseecho.test/de/category/handel')->assertSuccessful() + ->assertSee('', false); +}); + +test('portal content is no longer reachable on the hub and lands on its own portal', function () { + /** @var TestCase $this */ + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); + $company = Company::factory()->businessportal24()->create(['slug' => 'muster-gmbh']); + + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company) + ->create(['slug' => 'hub-umleitung-pm', 'published_at' => now()->subDay()]); + + // Meldung und Newsroom folgen ihrem eigenen Portal. + $this->get('https://pressekonto.test/de/press-release/hub-umleitung-pm') + ->assertStatus(301)->assertRedirect('https://businessportal24.test/de/press-release/hub-umleitung-pm'); + $this->get('https://pressekonto.test/de/newsroom/muster-gmbh') + ->assertStatus(301)->assertRedirect('https://businessportal24.test/de/newsroom/muster-gmbh'); + $this->get('https://pressekonto.test/de/newsroom/muster-gmbh/verwalten') + ->assertStatus(301)->assertRedirect('https://businessportal24.test/de/newsroom/muster-gmbh/verwalten'); + + // Kategorie und Jahresarchiv gibt es auf beiden Portalen: Standard-Portal, + // Query-Parameter bleiben erhalten. + $this->get('https://pressekonto.test/de/category/handel') + ->assertStatus(301)->assertRedirect('https://presseecho.test/de/category/handel'); + $this->get('https://pressekonto.test/de/category/handel/archiv/'.now()->year) + ->assertStatus(301)->assertRedirect('https://presseecho.test/de/category/handel/archiv/'.now()->year); + $this->get('https://pressekonto.test/de/suche?q=muster') + ->assertStatus(301)->assertRedirect('https://presseecho.test/de/suche?q=muster'); + + // Presseecho-Meldung geht auf presseecho, nicht auf das Standard-Portal des Hubs. + PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category) + ->for(Company::factory()->create(['portal' => Portal::Presseecho])) + ->create(['slug' => 'nur-presseecho-pm', 'published_at' => now()->subDay()]); + + $this->get('https://pressekonto.test/de/press-release/nur-presseecho-pm') + ->assertStatus(301)->assertRedirect('https://presseecho.test/de/press-release/nur-presseecho-pm'); + + // Die Portale liefern weiterhin selbst aus. + $this->get('https://businessportal24.test/de/press-release/hub-umleitung-pm')->assertSuccessful(); + $this->get('https://presseecho.test/de/category/handel')->assertSuccessful(); + + expect($release->fresh())->not->toBeNull(); +}); + +test('a duplicate release redirects to the portal that holds the original', function () { + /** @var TestCase $this */ + // Beide Fassungen tragen denselben Slug (so legt die API sie an) — die + // Weiterleitung darf nicht auf der Kopie landen. + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'IT', 'slug' => 'it']); + $company = Company::factory()->create(['portal' => Portal::Both]); + $text = '

Die Doppel GmbH eröffnet einen neuen Standort.

'; + + $copy = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company) + ->create(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHours(2)]); + $source = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company) + ->create(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHour()]); + + app(CanonicalAssigner::class)->assignFor($source); + + expect($copy->fresh()->canonical_press_release_id)->toBe($source->id); + + $this->get('https://pressekonto.test/de/press-release/doppel-standort') + ->assertStatus(301) + ->assertRedirect('https://businessportal24.test/de/press-release/doppel-standort'); +}); + +test('an unknown slug on the hub stays a 404 instead of bouncing to the portal', function () { + /** @var TestCase $this */ + $this->get('https://pressekonto.test/de/press-release/gibt-es-nicht')->assertNotFound(); + $this->get('https://pressekonto.test/de/newsroom/gibt-es-nicht')->assertNotFound(); +}); + +test('a removed release keeps its tombstone on the portal', function () { + /** @var TestCase $this */ + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); + + PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category) + ->for(Company::factory()->businessportal24()->create()) + ->create(['slug' => 'entfernte-pm', 'published_at' => now()->subDay(), 'gone_at' => now()]); + + $this->get('https://pressekonto.test/de/press-release/entfernte-pm') + ->assertStatus(301)->assertRedirect('https://businessportal24.test/de/press-release/entfernte-pm'); +}); + +test('posting to a portal route on the hub is not turned into a redirect', function () { + /** @var TestCase $this */ + $company = Company::factory()->businessportal24()->create(['slug' => 'post-gmbh', 'email' => 'presse@post.example']); + + // 301 auf ein POST würde die Anfrage verlieren — die Route bleibt. + $response = $this->post('https://pressekonto.test/de/newsroom/post-gmbh/verwalten', []); + + expect($response->status())->not->toBe(301) + ->and($company->fresh())->not->toBeNull(); });