diff --git a/.env.example b/.env.example index f18a9e52..e2a10a49 100644 --- a/.env.example +++ b/.env.example @@ -265,6 +265,8 @@ TRANSPARENCY_FREE_PORTALS_CHECKED_AT= # Doppelveröffentlichung (Roadmap Anhang 2, 1c.8): zweite Fassung verweist per # rel=canonical auf die Erstquelle, Zuordnung je Kategorie in config/canonical.php -# (Entscheidung E9). Aus = jede Fassung bleibt ihr eigenes Canonical. -CANONICAL_ASSIGNMENT_ENABLED=false +# (E9, entschieden 18.09.2026 — Standard an). Aus = jede Fassung bleibt ihr +# eigenes Canonical. ACHTUNG Reihenfolge beim ersten Deploy: migrate → +# press-releases:hash-content → press-releases:assign-canonicals. +CANONICAL_ASSIGNMENT_ENABLED=true CANONICAL_FALLBACK=first diff --git a/app/Http/Controllers/Web/LlmsTxtController.php b/app/Http/Controllers/Web/LlmsTxtController.php index cc57a110..a8e3d595 100644 --- a/app/Http/Controllers/Web/LlmsTxtController.php +++ b/app/Http/Controllers/Web/LlmsTxtController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\Category; +use App\Support\StaticPageCanonical; use App\Support\WebPortalContext; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -32,6 +33,9 @@ class LlmsTxtController extends Controller private function hub(string $base): string { + // Seiten, die auf allen drei Hosts liegen, werden mit der führenden + // Adresse genannt (Anhang 2, E10) — eine Adresse je Text. + $shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base; $presseecho = rtrim((string) config('domains.domains.presseecho.url'), '/'); $businessportal = rtrim((string) config('domains.domains.businessportal24.url'), '/'); @@ -70,6 +74,8 @@ class LlmsTxtController extends Controller private function portal(string $base): string { + // Wie im Hub: gemeinsame Seiten mit der führenden Adresse nennen. + $shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base; $context = WebPortalContext::fromRequest(); $brand = (array) ($context->domainConfig['brand'] ?? []); $name = ($brand['name'] ?? '').($brand['accent'] ?? ''); diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php index bd5d5009..7e50bc9a 100644 --- a/app/Http/Controllers/Web/SitemapController.php +++ b/app/Http/Controllers/Web/SitemapController.php @@ -9,6 +9,7 @@ use App\Models\PressRelease; use App\Scopes\PortalScope; use App\Services\PressRelease\CanonicalAssigner; use App\Services\Web\CategoryArchivePageData; +use App\Support\StaticPageCanonical; use App\Support\WebPortalContext; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -53,6 +54,18 @@ class SitemapController extends Controller return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']); } + /** + * Ob dieser Host die Seite führt (Anhang 2, E10): gemeinsame + * Informationsseiten liegen auf allen drei Hosts, in die Sitemap gehört + * nur die kanonische Adresse. Rechtstexte bleiben je Host gelistet. + */ + private function leadsPage(string $base, string $page): bool + { + $canonicalBase = StaticPageCanonical::baseFor($page); + + return $canonicalBase === null || parse_url($canonicalBase, PHP_URL_HOST) === parse_url($base, PHP_URL_HOST); + } + /** * @return list */ @@ -61,6 +74,10 @@ class SitemapController extends Controller $urls = [['loc' => $base.'/', 'lastmod' => null]]; foreach (self::HUB_PAGES as $page) { + if (! $this->leadsPage($base, $page)) { + continue; + } + $urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null]; } @@ -77,6 +94,10 @@ class SitemapController extends Controller $urls = [['loc' => $base.'/de', 'lastmod' => null]]; foreach (self::PORTAL_PAGES as $page) { + if (! $this->leadsPage($base, $page)) { + continue; + } + $urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null]; } diff --git a/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php new file mode 100644 index 00000000..9c9149f7 --- /dev/null +++ b/app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php @@ -0,0 +1,34 @@ +getHost(), 'pressekonto')) { + return $next($request); + } + + $base = StaticPageCanonical::portalOnlyRedirect($request->route()?->getName()); + + if ($base === null) { + return $next($request); + } + + return redirect($base.$request->getPathInfo(), 301); + } +} diff --git a/app/Support/StaticPageCanonical.php b/app/Support/StaticPageCanonical.php new file mode 100644 index 00000000..3b7928f6 --- /dev/null +++ b/app/Support/StaticPageCanonical.php @@ -0,0 +1,116 @@ +getHost(); + + // Portal-Inhalte (Kategorie, Archiv, Meldung, Newsroom) sind auch + // über den Hub erreichbar und rendern dort das Portal. Ihr Canonical + // gehört auf das Portal, nicht auf pressekonto. + if ($portalContextApplied && is_string($domainUrl) && $domainUrl !== '') { + return rtrim($domainUrl, '/'); + } + + if (is_string($domainUrl) && $domainUrl !== '' && parse_url($domainUrl, PHP_URL_HOST) === $host) { + return rtrim($domainUrl, '/'); + } + + foreach ((array) config('domains.domains', []) as $domain) { + $url = $domain['url'] ?? null; + + if (is_string($url) && parse_url($url, PHP_URL_HOST) === $host) { + return rtrim($url, '/'); + } + } + + return rtrim(request()->getSchemeAndHttpHost(), '/'); + } + + /** + * Adresse für einen Link auf eine benannte Seite: normalerweise die des + * aufgerufenen Hosts — bei Portalseiten auf dem Hub direkt die des + * führenden Portals, damit der Besucher nicht über eine Weiterleitung + * läuft. + */ + public static function publicUrlFor(string $routeName): string + { + $url = route($routeName); + $portalBase = self::portalOnlyRedirect($routeName); + + if ($portalBase !== null && str_contains(request()->getHost(), 'pressekonto')) { + return $portalBase.(string) parse_url($url, PHP_URL_PATH); + } + + return $url; + } + + /** + * Seiten, die es nur auf den Portalen gibt — auf dem Hub führen sie auf + * das führende Portal statt auf eine Fassung mit fremdem Branding. + */ + public static function portalOnlyRedirect(?string $routeName): ?string + { + if ($routeName === null || ! in_array($routeName, (array) config('canonical.portal_only_pages', []), true)) { + return null; + } + + $url = config('domains.domains.'.config('canonical.portal_only_host', 'presseecho').'.url'); + + return is_string($url) && $url !== '' ? rtrim($url, '/') : null; + } +} diff --git a/app/Support/WebPortalContext.php b/app/Support/WebPortalContext.php index 8aab4dc4..778cf05d 100644 --- a/app/Support/WebPortalContext.php +++ b/app/Support/WebPortalContext.php @@ -3,6 +3,7 @@ namespace App\Support; use App\Enums\Portal; +use Illuminate\Support\Facades\View; /** * Öffentlicher Portal-Kontext (Presseecho/Businessportal24) einer Web-Landingpage, @@ -39,6 +40,12 @@ class WebPortalContext $portal = self::portalFromHost(); $domainKey = $portal === Portal::Presseecho ? 'presseecho' : 'businessportal24'; + // Merker fürs Layout: Diese Seite rendert Portal-Inhalte, die + // Domain-Config zeigt also auf ein Portal — auch wenn der Aufruf über + // den Hub kam. Der Self-Canonical muss dann dem Portal folgen, nicht + // dem aufgerufenen Host (Anhang 2, E10). + View::share('webPortalContextApplied', true); + return new self( domainKey: $domainKey, portal: $portal, diff --git a/bootstrap/app.php b/bootstrap/app.php index b0842eb6..2c486bb8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\BasicAuthMiddleware; use App\Http\Middleware\LogApiUsage; use App\Http\Middleware\RecordMailLinkClick; +use App\Http\Middleware\RedirectPortalOnlyPagesFromHub; use App\Http\Middleware\RedirectWwwToApex; use App\Http\Middleware\RejectLegacyApiKeys; use App\Http\Middleware\SecurityHeaders; @@ -50,6 +51,13 @@ return Application::configure(basePath: dirname(__DIR__)) // Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen. $middleware->append(SetEdition::class); + // Portalseiten auf dem Hub (Anhang 2, E10): /de/preise & Co. gibt es + // nur auf den Portalen — 301 statt einer Fassung mit fremdem Branding + // unter pressekonto.com. In der web-Gruppe, nicht global: die + // Middleware liest den Routennamen, und der steht erst nach dem + // Routing fest. + $middleware->web(append: [RedirectPortalOnlyPagesFromHub::class]); + // Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt // werden: rollen- und verifizierungsbewusst statt fix auf /dashboard, // sonst landet ein Customer dort im 403 und sitzt fest. diff --git a/config/canonical.php b/config/canonical.php index 7600cd34..c5016ee7 100644 --- a/config/canonical.php +++ b/config/canonical.php @@ -19,7 +19,7 @@ return [ - 'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', false), + 'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', true), // DE-Slug der Hauptkategorie => Portal, das die Erstquelle führt. // Vorschlag zu E9; Kategorien ohne Eintrag fallen auf `fallback`. @@ -44,4 +44,46 @@ return [ // bleibt Erstquelle; alternativ ein Portalname als feste Vorgabe. 'fallback' => env('CANONICAL_FALLBACK', 'first'), + /* + |---------------------------------------------------------------------- + | Statische Seiten auf drei Hosts (E10, entschieden 18.09.2026) + |---------------------------------------------------------------------- + | + | faq, hilfe, Transparenzseiten & Co. werden auf Hub und beiden Portalen + | mit identischem Text ausgeliefert — dieselbe Doppelung wie bei den + | Meldungen, nur dreifach. Die Portale führen (ältere Domains), + | presseecho ist der Standard; die API-Dokumentation gehört zum Hub. + | + | Routenname => Domain-Schlüssel aus config/domains.php. Nicht gelistete + | Seiten bleiben self-canonical je Host. + | + | BEWUSST NICHT gelistet: impressum, datenschutz, agb, richtlinien, + | melden, kuendigung. Jede Domain ist ein eigenes Angebot mit eigener + | Anbieterkennzeichnung, eigenem Meldeweg und eigenen Bedingungen — + | diese Seiten müssen je Host für sich stehen und auffindbar bleiben. + | + */ + + 'static_page_host' => [ + 'faq' => 'presseecho', + 'hilfe' => 'presseecho', + 'gute-pressemitteilung' => 'presseecho', + 'was-kostet-eine-pressemitteilung' => 'presseecho', + 'pressemitteilung-veroeffentlichen-ohne-abo' => 'presseecho', + 'pressemitteilung-loeschen-oder-aendern-lassen' => 'presseecho', + 'pressemitteilung-kostenlos-veroeffentlichen' => 'presseecho', + 'api' => 'pressekonto', + ], + + /* + | Seiten, die es nur auf den Portalen gibt. Auf dem Hub wurden sie bisher + | mit Portal-Branding unter pressekonto.com ausgeliefert (der Portal- + | Kontext fällt dort auf businessportal24 zurück) — jetzt 301 auf das + | führende Portal. + */ + + 'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche'], + + 'portal_only_host' => 'presseecho', + ]; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 5a95ca10..e4aa96f5 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -241,8 +241,8 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. | E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 | | ~~E7~~ | **Entschieden 17.09.2026 (Anhang 1):** keine kostenfreie Einreichung, dauerhaft | 2.4, 2.6 | | E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`), Entscheidung nach Verteilung aus 1c.4 (Anhang 1) | 1c.5 | -| E9 | Kanonische Zuordnung je Kategorie — Vorschlag in `config/canonical.php`, Mechanismus gebaut und aus (`CANONICAL_ASSIGNMENT_ENABLED`) (Anhang 2) | 1c.8 | -| E10 | Transparenz-/Statikseiten: Hub oder Portale als Canonical — Befund: dreifach vorhanden (Anhang 2, Protokoll Teil 7) | 2.4 | +| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 8) | 1c.8 | +| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 8) | 2.4 | --- diff --git a/dev/2026-09-01/roadmap-anhang1-2026.md b/dev/2026-09-01/roadmap-anhang1-2026.md index e0d4a977..d3ee3cf6 100644 --- a/dev/2026-09-01/roadmap-anhang1-2026.md +++ b/dev/2026-09-01/roadmap-anhang1-2026.md @@ -9,7 +9,7 @@ | Änderung | Betrifft | Art | |---|---|---| | Crawler-Abruf-Protokoll als eigenes Arbeitspaket | 1c.0 | neu | -| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.6 | ersetzt | +| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.N9A59YXJai6 | ersetzt | | Erstquelle gegen Kopie als Argument für die Direktveröffentlichung | 1d | neu | | Transparenzseiten ausformuliert, kein Wettbewerbervergleich | 2.4 | ersetzt | | Zuschriften an Portalübersichten ausformuliert, zwei konkrete Ziele | 2.6 | ersetzt | diff --git a/dev/2026-09-01/roadmap-anhang2-2026.md b/dev/2026-09-01/roadmap-anhang2-2026.md index 4af03e8c..174cefc1 100644 --- a/dev/2026-09-01/roadmap-anhang2-2026.md +++ b/dev/2026-09-01/roadmap-anhang2-2026.md @@ -99,7 +99,7 @@ Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nicht gebraucht — das Tageskontingent geht an die beiden Portale. Zwei Dinge sind dort trotzdem zu prüfen: - ~~Stehen Konto- und Login-Bereiche auf `noindex`?~~ **17.09.2026: ja, jetzt per Meta-Tag in allen Portal-Layouts.** Die Indexierungsübersicht der neuen Property zeigt, was Google von dort erfasst hat. -- **Offene Entscheidung E10:** Wohin gehören die Transparenzseiten aus 2.4 — auf den Hub oder auf die Portale? Die Portale haben die deutlich ältere Domain, der Hub die thematisch passendere Adresse. +- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar (Canonical zeigt aufs Portal, keine Sitemap-Einträge) — Empfehlung 301 auf businessportal24, ~34.000 Adressen, wartet auf Freigabe. --- @@ -107,5 +107,5 @@ Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nich | Nr. | Frage | Betrifft | |---|---|---| -| E9 | Kanonische Zuordnung je Kategorie: welches Portal führt welches Themenfeld — *Vorschlag liegt in `config/canonical.php`, Mechanismus wartet auf `CANONICAL_ASSIGNMENT_ENABLED=true`* | 1c.8 | -| E10 | Wohin gehören die Transparenzseiten aus 2.4 — Hub oder Portale. *Befund 17.09.2026: alle statischen Seiten liegen auf drei Hosts mit je eigenem Canonical; Empfehlung Portale (Protokoll Teil 7)* | 2.4 | \ No newline at end of file +| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung wie vorgeschlagen (businessportal24: finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; presseecho: alle übrigen inkl. sonstiges). Zuordnung ist aktiv, `CANONICAL_ASSIGNMENT_ENABLED` steht standardmäßig auf true | 1c.8 | +| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 8) | 2.4 | \ No newline at end of file diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index f3a53455..bb8fa2b0 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1998,3 +1998,93 @@ entkoppelt. Pint, Larastan ohne Befund. Hintergrund), dann `press-releases:duplicates-report`. Erst nach E9: `CANONICAL_ASSIGNMENT_ENABLED=true`, `press-releases:assign-canonicals --dry-run`, dann ohne Flag, `view:cache`, Sitemaps prüfen. + +## Teil 8 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026) + +### E9 — kanonische Zuordnung aktiv + +Zuordnung wie vorgeschlagen übernommen (`config/canonical.php`): +businessportal24 führt finanzen, handel, it, recht, wissenschaft-forschung, +motor-verkehr; presseecho führt haus, kultur, medizin, mode, politik, +reisen, sport, sonstiges. `CANONICAL_ASSIGNMENT_ENABLED` steht jetzt +standardmäßig auf `true` — ohne `.env`-Eintrag ist die Zuordnung an. + +Auf der Dev-Kopie verifiziert: 9.092 Paare zugeordnet, die Kopie trägt +`rel=canonical` auf die Erstquelle, steht nicht mehr in der Sitemap ihres +Portals, die Erstquelle schon. + +### E10 — eine Adresse je Text, Rechtstexte ausgenommen + +Befund: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten, api +und alle Rechtstexte liegen auf **allen drei Hosts** mit identischem Text +(nur Markenname getauscht, per Prüfsumme bestätigt) und trugen je einen +eigenen Self-Canonical. + +Umgesetzt (`config/canonical.static_page_host`, `App\Support\StaticPageCanonical`): + +| Seiten | Führender Host | +|---|---| +| faq, hilfe, gute-pressemitteilung, 4 Transparenzseiten | presseecho | +| api (Doku des Publisher-Bereichs) | pressekonto | +| impressum, datenschutz, agb, richtlinien, melden, kuendigung | **je Host selbst** | + +**Rechtstexte bewusst ausgenommen** — das ist eine Abweichung von „alles auf +die Portale". Jede Domain ist ein eigenes Angebot mit eigener +Anbieterkennzeichnung und eigenem Meldeweg; wer auf businessportal24.com +das Impressum aufruft, muss das Impressum dieser Domain bekommen, und es +muss für sich auffindbar bleiben. Ein Canonical auf eine fremde Domain +würde dem widersprechen. Inhaltlich sind die Texte identisch — das ist bei +Pflichtangaben unkritisch. + +Mitgezogen: Sitemap listet je Seite nur noch den führenden Host (Hub +behält api + Rechtstexte), `llms.txt` nennt die führende Adresse, `og:url` +folgt dem Canonical. + +### Erreichbarkeit pressekonto.com — geprüft, drei Befunde + +Alle 24 Hub-Adressen und jeder Link der Hub-Startseite geprüft: **alles +200**, unbekannte Pfade 404, `sitemap.xml`, `sitemap-news.xml`, +`llms.txt`, `robots.txt`, `/docs/api/v1` in Ordnung. + +1. **Portalseiten unter pressekonto.com** (behoben): `/de/preise`, + `/de/veroeffentlichen`, `/de/newsrooms`, `/de/suche` wurden auf dem Hub + ausgeliefert — mit dem Branding und Titel von **businessportal24**, + weil `WebPortalContext::fromRequest()` auf dem Hub auf dieses Portal + zurückfällt. Jetzt 301 auf presseecho + (`RedirectPortalOnlyPagesFromHub`, web-Gruppe). Die + Transparenz-Navigation verlinkt „Tarife & Preise" auf dem Hub direkt + auf presseecho, ohne Umweg über die Weiterleitung. +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. + +Technischer Nebeneffekt: Der Self-Canonical kommt jetzt aus dem +tatsächlich aufgerufenen Host (`StaticPageCanonical::selfBase()`) statt aus +der geteilten Domain-Config. Portal-Inhalte behalten ihr Portal — +`WebPortalContext::fromRequest()` setzt dafür den Merker +`webPortalContextApplied`. + +### Tests / Deploy + +`HubPortalPageRedirectTest` (19), angepasste Sitemap-Erwartungen in +`StaticPagesTest` und `TransparencyPagesTest`. Gesamtlauf 1.804 grün +(eine Ausnahme: `ManualInvoicePdfTest` schlägt unabhängig von diesen +Änderungen fehl — mit und ohne Stash reproduziert, Anrede „Frau Erika +Muster" fehlt; gehört zum Rechnungs-Paket). + +**Deploy:** `view:cache`/`route:cache` neu, `php artisan cache:clear` für +die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor +müssen `press-releases:hash-content` und `press-releases:assign-canonicals` +gelaufen sein, sonst passiert schlicht nichts. diff --git a/resources/views/components/web/transparency-nav.blade.php b/resources/views/components/web/transparency-nav.blade.php index 2de90673..b592b16e 100644 --- a/resources/views/components/web/transparency-nav.blade.php +++ b/resources/views/components/web/transparency-nav.blade.php @@ -24,7 +24,7 @@ @if ($routeName === $current) {{ $label }} @else - {{ $label }} → + {{ $label }} → @endif @endforeach diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php index 2f5cc5ab..6cb3eb64 100644 --- a/resources/views/web/layouts/web-master.blade.php +++ b/resources/views/web/layouts/web-master.blade.php @@ -14,8 +14,13 @@ $pageDescription = html_entity_decode(trim($__env->yieldContent('meta_description')), ENT_QUOTES, 'UTF-8') ?: $siteName; // Self-Canonical als Default aus der Portal-URL + Request-Pfad (ohne Query): // pro Portal/Domain getrennt und unabhängig von URL::forceRootUrl – verhindert Cross-Portal-Duplicate. - $portalBase = rtrim($domainUrl ?? config('app.url'), '/'); - $canonicalUrl = trim($__env->yieldContent('canonical')) ?: ($portalBase.request()->getPathInfo()); + $portalBase = \App\Support\StaticPageCanonical::selfBase($domainUrl ?? null, ($webPortalContextApplied ?? false) === true); + // Statische Seiten liegen auf Hub UND beiden Portalen mit identischem + // Text (Anhang 2, E10): dort führt ein Host, die anderen verweisen + // dorthin. Rechtstexte sind ausgenommen — siehe config/canonical.php. + $canonicalUrl = trim($__env->yieldContent('canonical')) + ?: (\App\Support\StaticPageCanonical::urlFor(request()->route()?->getName(), request()->getPathInfo()) + ?: $portalBase.request()->getPathInfo()); $ogType = trim($__env->yieldContent('og_type')) ?: 'website'; $ogImage = trim($__env->yieldContent('og_image')); @endphp diff --git a/routes/web.php b/routes/web.php index 4feca6ae..bb636436 100644 --- a/routes/web.php +++ b/routes/web.php @@ -244,8 +244,15 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func // (Platzhalter-Adresse/-Team); Alt-Links landen per 301 auf der Startseite. // Der Kontakt-POST bleibt für das Hub-Kontakt-Modal erhalten. Route::view('faq', 'web.faq')->name('faq'); - Route::get('kontakt', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301)); - Route::get('ueber-uns', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301)); + // Ziel ist die Startseite des jeweiligen Hosts. Auf dem Hub liegt die + // unter `/` — ein Umweg über `/de` wäre dort eine zweite Weiterleitung. + $homeRedirect = fn (string $edition) => redirect(rtrim( + request()->getSchemeAndHttpHost().(str_contains(request()->getHost(), 'pressekonto') ? '/' : '/'.$edition), + '/' + ) ?: request()->getSchemeAndHttpHost().'/', 301); + + Route::get('kontakt', $homeRedirect); + Route::get('ueber-uns', $homeRedirect); Route::post('kontakt', ContactController::class) ->middleware('throttle:10,1') ->name('kontakt.store'); diff --git a/tests/Feature/Web/HubPortalPageRedirectTest.php b/tests/Feature/Web/HubPortalPageRedirectTest.php new file mode 100644 index 00000000..3c3d0940 --- /dev/null +++ b/tests/Feature/Web/HubPortalPageRedirectTest.php @@ -0,0 +1,74 @@ +get('https://pressekonto.test'.$path) + ->assertStatus(301) + ->assertRedirect('https://presseecho.test'.$path); +})->with(['/de/preise', '/de/veroeffentlichen', '/de/newsrooms', '/de/suche', '/en/preise']); + +test('the portals keep serving those pages themselves', function (string $host) { + /** @var TestCase $this */ + $this->get($host.'/de/preise')->assertSuccessful(); +})->with(['https://presseecho.test', 'https://businessportal24.test']); + +test('shared info pages name one leading host on all three domains', function (string $host) { + /** @var TestCase $this */ + // Anhang 2, E10: Informationsseiten liegen dreifach vor — presseecho + // führt, die API-Doku gehört zum Hub. + $this->get($host.'/de/faq')->assertSuccessful() + ->assertSee('', false); + $this->get($host.'/de/was-kostet-eine-pressemitteilung')->assertSuccessful() + ->assertSee('', false) + ->assertSee('', false); + $this->get($host.'/de/api')->assertSuccessful() + ->assertSee('', false); +})->with(['https://pressekonto.test', 'https://presseecho.test', 'https://businessportal24.test']); + +test('legal pages stay with their own domain', function (string $host, string $path) { + /** @var TestCase $this */ + // Jede Domain ist ein eigenes Angebot: Impressum, AGB, Datenschutz, + // Richtlinien und Meldeweg gehören zu dem Host, auf dem sie stehen. + $this->get($host.$path)->assertSuccessful() + ->assertSee('', false); +})->with([ + ['https://pressekonto.test', '/de/impressum'], + ['https://businessportal24.test', '/de/agb'], + ['https://businessportal24.test', '/de/datenschutz'], + ['https://presseecho.test', '/de/melden'], + ['https://businessportal24.test', '/de/richtlinien'], +]); + +test('the english edition keeps the leading host too', function () { + /** @var TestCase $this */ + $this->get('https://businessportal24.test/en/hilfe')->assertSuccessful() + ->assertSee('', false); +}); + +test('the retired contact and about pages redirect to the home page in one hop', function (string $host, string $target) { + /** @var TestCase $this */ + $this->get($host.'/de/kontakt')->assertStatus(301)->assertRedirect($target); + $this->get($host.'/de/ueber-uns')->assertStatus(301)->assertRedirect($target); +})->with([ + // Auf dem Hub liegt die Startseite unter `/` — der frühere Weg über + // `/de` war eine zweite Weiterleitung. + ['https://pressekonto.test', 'https://pressekonto.test'], + ['https://presseecho.test', 'https://presseecho.test/de'], +]); + +test('portal content served through the hub keeps the 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. + $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); +}); diff --git a/tests/Feature/Web/StaticPagesTest.php b/tests/Feature/Web/StaticPagesTest.php index a2400928..bf70b67e 100644 --- a/tests/Feature/Web/StaticPagesTest.php +++ b/tests/Feature/Web/StaticPagesTest.php @@ -263,7 +263,9 @@ test('the good-release help page explains the check without selling anything', f $body = Str::between($response->getContent(), 'class="pm-body', 'aria-labelledby="partner-heading"'); expect($body)->not->toContain('/preise')->not->toContain('Credits'); - // Aus dem Hilfe-Center und der Sitemap erreichbar. + // Aus dem Hilfe-Center erreichbar; in der Sitemap steht sie beim + // führenden Host (Anhang 2, E10) — presseecho, nicht businessportal24. $this->get('https://businessportal24.test/de/hilfe')->assertSee('/de/gute-pressemitteilung', false); - $this->get('https://businessportal24.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false); + $this->get('https://presseecho.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false); + $this->get('https://businessportal24.test/sitemap.xml')->assertDontSee('/de/gute-pressemitteilung', false); }); diff --git a/tests/Feature/Web/TransparencyPagesTest.php b/tests/Feature/Web/TransparencyPagesTest.php index 5b48bbdc..78ff80cc 100644 --- a/tests/Feature/Web/TransparencyPagesTest.php +++ b/tests/Feature/Web/TransparencyPagesTest.php @@ -123,6 +123,9 @@ test('the pages are linked from the pricing page, the help centre and the sitema $this->get('https://presseecho.test/llms.txt')->assertSuccessful() ->assertSee('/de/pressemitteilung-kostenlos-veroeffentlichen', false); + // Die Seiten liegen auf allen drei Hosts; in der Sitemap steht nur die + // führende Adresse (Anhang 2, E10) — beim Hub bleibt die API-Doku. $this->get('https://pressekonto.test/sitemap.xml')->assertSuccessful() - ->assertSee('https://pressekonto.test/de/pressemitteilung-loeschen-oder-aendern-lassen', false); + ->assertDontSee('/de/pressemitteilung-loeschen-oder-aendern-lassen', false) + ->assertSee('https://pressekonto.test/de/api', false); });