where('token', '[A-Za-z0-9]{40,}') ->middleware('throttle:30,1') ->name('press-releases.preview'); Route::get('/media/press-release-images/{image}/{variant?}', PressReleaseImageMediaController::class) ->whereNumber('image') ->where('variant', '[A-Za-z0-9_-]+') ->middleware('throttle:600,1') ->name('media.press-release-images.show'); Route::get('/media/press-release-attachments/{attachment}', PressReleaseAttachmentMediaController::class) ->whereNumber('attachment') ->middleware('throttle:600,1') ->name('media.press-release-attachments.show'); // Gemeinsame Web-Routes für alle Landingpages // Jede Landing-Page hat das gleiche Gerüst, aber unterschiedliches Styling // Dünner Delegations-Wrapper auf die Single Source of Truth (DomainAssetContext), // damit Bare-Root- und Edition-Home-Route unverändert per Closure aufrufen können. $applyWebDomainConfig = static fn (string $domainKey): array => DomainAssetContext::applyWebDomainConfig($domainKey); // --------------------------------------------------------------------------- // Bare Root: Publisher-Hub (pressekonto) oder Redirect auf die Default-Ausgabe. // --------------------------------------------------------------------------- Route::get('/', function () use ($applyWebDomainConfig) { $domain = request()->getHost(); if (str_contains($domain, 'pressekonto')) { $applyWebDomainConfig('pressekonto'); return view('web.pressekonto', app(HubPageData::class)->build()); } return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301); })->name('home'); // --------------------------------------------------------------------------- // Legacy .html-URLs -> saubere 301-Weiterleitung (vor der Edition-Gruppe, damit // `category/x.html` nicht als Slug `x.html` in die Kategorie-Route läuft). // --------------------------------------------------------------------------- $htmlRedirect = static function (string $edition, string $path) { $base = request()->getSchemeAndHttpHost(); $clean = trim((string) preg_replace('/\.html$/', '', $path), '/'); // Account/Anmeldung lebt im Publisher-Hub (pressekonto), nicht am Portal. // Die öffentlichen Spokes haben kein eigenes Login-System (Hub-and-Spoke). $hubAliases = [ 'registration' => '/register', 'login' => '/login', ]; if (array_key_exists($clean, $hubAliases)) { $hubUrl = rtrim((string) config('domains.domain_portal_url'), '/'); return redirect($hubUrl.$hubAliases[$clean], 301); } // Entfallene Rubriken-Übersicht -> Startseite der Ausgabe. if ($clean === 'kategorien') { return redirect($base.'/'.$edition, 301); } // Ratgeber-Strecke der Altseite (/de/presse/*.html) -> konsolidierte FAQ. // Hier steckt der SEO-Wert; alle Einzelseiten bündeln wir 301 auf /faq. if ($clean === 'presse' || str_starts_with($clean, 'presse/')) { return redirect($base.'/'.$edition.'/faq', 301); } // Alte Firmen-Pressemappe (presskit/{id}/{slug}) entspricht dem Newsroom. // Feinmapping über die Legacy-ID: companies.(legacy_portal, legacy_id) ist // unique-indiziert, ein Lookup liefert den heutigen Newsroom-Slug. Bei // gültigem öffentlichem Newsroom 301 direkt dorthin (ein Hop), sonst sauber // aufs Verzeichnis statt in einen 404. if (preg_match('#^presskit/(\d+)#', $clean, $presskitMatch) === 1) { $legacyPortal = str_contains(request()->getHost(), 'presseecho') ? Portal::Presseecho->value : Portal::Businessportal24->value; $company = Company::query() ->withoutGlobalScope(PortalScope::class) ->where('legacy_portal', $legacyPortal) ->where('legacy_id', (int) $presskitMatch[1]) ->first(); $hasPublicNewsroom = $company && $company->is_active && $company->pressReleases() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive() ->exists(); return redirect( $hasPublicNewsroom ? $base.'/'.$edition.'/newsroom/'.$company->slug : $base.'/'.$edition.'/newsrooms', 301 ); } // Pressemappen-Pfade ohne numerische ID -> Newsrooms-Verzeichnis. if ($clean === 'presskit' || str_starts_with($clean, 'presskit/')) { return redirect($base.'/'.$edition.'/newsrooms', 301); } // Legacy-Slugs des Altsystems (meist englisch) -> neue deutsche Pfade. // team/partner/karriere/kontakt/ueber-uns: Seiten wurden zurückgebaut // (R3.2 bzw. Platzhalter-Inhalte) -> Startseite. $staticAliases = [ 'pricelist' => 'preise', 'service' => 'veroeffentlichen', 'contact' => '', 'imprint' => 'impressum', 'terms' => 'agb', 'privacypolicy' => 'datenschutz', 'team' => '', 'partner' => '', 'karriere' => '', 'kontakt' => '', 'ueber-uns' => '', 'cookies' => 'datenschutz', ]; // Bereits saubere Zielpfade der neuen Struktur (Pfad bleibt erhalten). $staticPages = [ 'preise', 'faq', 'veroeffentlichen', 'suche', 'newsrooms', 'api', 'hilfe', 'gute-pressemitteilung', 'impressum', 'datenschutz', 'agb', ]; if (isset($staticAliases[$clean])) { $target = $staticAliases[$clean]; } elseif (str_starts_with($clean, 'category/') || str_starts_with($clean, 'press-release/')) { $target = $clean; } elseif ($clean === '' || str_contains($clean, '/')) { $target = $clean; } elseif (in_array($clean, $staticPages, true)) { $target = $clean; } else { // Flache Detail-URL der Altseite: /{edition}/{slug}.html $target = 'press-release/'.$clean; } return redirect(rtrim($base.'/'.$edition.'/'.$target, '/'), 301); }; // --------------------------------------------------------------------------- // Entfallene Sprach-/Regionsvarianten (de-ch, de-at) -> deutsche Ausgabe. // Die echten Regionen-Übersichten (/region/{slug}) sind für einen späteren // Schritt geplant; bis dahin verhindern diese 301 tote Bestands-Links. // --------------------------------------------------------------------------- $legacyRegions = ['de-ch', 'de-at']; Route::get('{region}', function (string $region) { return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301); })->whereIn('region', $legacyRegions); Route::get('{region}/{path}', function (string $region, string $path) { return redirect(request()->getSchemeAndHttpHost().'/'.SetEdition::DEFAULT_EDITION, 301); })->whereIn('region', $legacyRegions)->where('path', '.*'); Route::get('{edition}/{path}', function (string $edition, string $path) use ($htmlRedirect) { return $htmlRedirect($edition, $path); })->where('path', '.*\.html')->whereIn('edition', SetEdition::EDITIONS); // Top-Level Legacy ohne Ausgabe-Präfix -> Default-Ausgabe. Route::get('{path}', function (string $path) use ($htmlRedirect) { return $htmlRedirect(SetEdition::DEFAULT_EDITION, $path); })->where('path', '.*\.html'); // Alte Symfony-Medien-URLs (Bestands-Backlinks): /thumbnails/... und // /uploads/... -> 301 auf die neue Storage-URL, sonst 404. Route::get('{prefix}/{dir}/{path}', LegacyMediaRedirectController::class) ->whereIn('prefix', ['thumbnails', 'uploads']) ->whereIn('dir', ['company', 'pressreleaseimage', 'pressreleaseimage_', '_pressreleaseimage']) ->where('path', '.+') ->name('legacy.media'); // --------------------------------------------------------------------------- // Öffentliche Inhalte je Ausgabe (Sprache): /de/... und /en/... // --------------------------------------------------------------------------- Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(function () { // Startseite je Ausgabe Route::get('/', HomeController::class)->name('web.home'); // Kategorie / Branchenseite (z.B. /de/category/energie-klima) Route::get('category/{slug}', CategoryController::class)->name('kategorie'); // Jahresarchiv einer Branche (Roadmap 2.3): /de/category/handel/archiv/2019 Route::get('category/{slug}/archiv/{year?}', CategoryArchiveController::class) ->where('year', '[0-9]{4}') ->name('kategorie.archiv'); // Detailseite einer Pressemitteilung (z.B. /de/press-release/ki-revolution) Route::get('press-release/{slug}', ReleaseDetailController::class)->name('release.detail'); // Share-Tracking der Detailseite (Teilen-Buttons): loggt Kanal-Klicks // als press_release_events, ohne Personenbezug. Route::post('press-release/{slug}/share', ReleaseShareTrackingController::class) ->middleware('throttle:30,1') ->name('release.share'); // Preise / Tarife (datengetrieben: Plan + config/billing + config/credits) Route::get('preise', PreiseController::class)->name('preise'); // Statische Seiten. Kontakt- und Über-uns-Seite wurden zurückgebaut // (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)); Route::post('kontakt', ContactController::class) ->middleware('throttle:10,1') ->name('kontakt.store'); Route::get('veroeffentlichen', VeroeffentlichenController::class)->name('veroeffentlichen'); Route::get('suche', SucheController::class)->name('suche'); Route::get('newsrooms', NewsroomsController::class)->name('newsrooms'); Route::get('newsroom/{slug}', NewsroomDetailController::class)->name('newsroom'); // „Diesen Newsroom verwalten" (Roadmap 1b.2): Anmeldelink an die // hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit. Route::get('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'show'])->name('newsroom.claim'); Route::post('newsroom/{slug}/verwalten', [NewsroomClaimController::class, 'store']) ->middleware('throttle:10,1') ->name('newsroom.claim.store'); Route::view('api', 'web.api')->name('api'); Route::view('hilfe', 'web.hilfe')->name('hilfe'); // Roadmap 1d.1: Hilfeseite ohne kommerziellen Anteil — Ziel des // Textbausteins in der Ablehnungsmail (1d.2). Route::view('gute-pressemitteilung', 'web.gute-pressemitteilung')->name('gute-pressemitteilung'); Route::view('impressum', 'web.impressum')->name('impressum'); // Gesetzlicher Kündigungsbutton (§ 312k BGB): öffentliche Bestätigungsseite // + Eingang der Kündigungserklärung, ohne Login erreichbar. Route::get('kuendigung', [TerminationController::class, 'show'])->name('kuendigung'); Route::post('kuendigung', [TerminationController::class, 'store']) ->middleware('throttle:10,1') ->name('kuendigung.store'); Route::view('datenschutz', 'web.datenschutz')->name('datenschutz'); Route::view('agb', 'web.agb')->name('agb'); // Cookie-Seite zurückgebaut (Entscheidung 03.07.): Cookies sind in der // Datenschutzerklärung (Abschnitt 4) geregelt; kein Banner nötig. Route::get('cookies', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition.'/datenschutz', 301))->name('cookies'); // Rechtstexte-Architektur (Entscheidung 03.07.2026): Veröffentlichungs- // richtlinien (Bestandteil der AGB) und DSA-Meldeverfahren, auf allen // drei Domains ausgeliefert. Route::view('richtlinien', 'web.richtlinien')->name('richtlinien'); Route::view('melden', 'web.melden')->name('melden'); // T10: Eigenständige Meldeformulare mit URL-Feld (Meldeverfahren Weg 1/2) // — Vorgangsnummer + Eingangsbestätigung per E-Mail (DSA Art. 16). Route::get('melden/{way}', [ReportFormController::class, 'show']) ->whereIn('way', ReportFormController::WAYS) ->name('melden.form'); Route::post('melden/{way}', [ReportFormController::class, 'store']) ->middleware('throttle:10,1') ->whereIn('way', ReportFormController::WAYS) ->name('melden.form.store'); // Meldeverfahren v2 (Phase 3): PM melden/ändern — URL einfügen → // Magic-Link an die hinterlegte Pressekontaktadresse → verifizierte // Anliegen-Auswahl (vier Spuren). Route::get('melden/pm', [PressReleaseReportController::class, 'showIntake'])->name('melden.pm'); Route::post('melden/pm', [PressReleaseReportController::class, 'requestLink']) ->middleware('throttle:10,1') ->name('melden.pm.store'); Route::get('melden/pm/verifizieren/{token}', [PressReleaseReportController::class, 'verify']) ->where('token', '[A-Za-z0-9]{40,}') ->middleware('throttle:10,1') ->name('melden.pm.verify'); Route::get('melden/pm/anliegen', [PressReleaseReportController::class, 'showConcerns'])->name('melden.pm.anliegen'); Route::post('melden/pm/anliegen', [PressReleaseReportController::class, 'storeConcern']) ->middleware('throttle:10,1') ->name('melden.pm.anliegen.store'); }); // --------------------------------------------------------------------------- // SEO: Sitemap + robots.txt, host-aware (Hub vs. Spokes). Die frühere // statische public/robots.txt wurde entfernt — sie hätte diese Routen // verschattet (Webserver liefert existierende Dateien direkt aus). // --------------------------------------------------------------------------- Route::get('/sitemap.xml', SitemapController::class)->name('sitemap'); Route::get('/sitemap-news.xml', NewsSitemapController::class)->name('sitemap.news'); Route::get('/llms.txt', LlmsTxtController::class)->name('llms'); Route::get('/robots.txt', function () { $base = rtrim(request()->getSchemeAndHttpHost(), '/'); $isHub = str_contains(request()->getHost(), 'pressekonto'); $lines = [ 'User-agent: *', 'Disallow: /admin', 'Disallow: /login', 'Disallow: /register', 'Disallow: /de/suche', 'Disallow: /en/suche', '', 'Sitemap: '.$base.'/sitemap.xml', // News-Sitemap nur auf den Spokes — der Hub hat keine Meldungen. ...($isHub ? [] : ['Sitemap: '.$base.'/sitemap-news.xml']), '', ]; return response(implode("\n", $lines), 200, ['Content-Type' => 'text/plain; charset=UTF-8']); })->name('robots'); // --------------------------------------------------------------------------- // Sprach-/Edition-unabhängige Routen (API-Doku). // --------------------------------------------------------------------------- Route::get('/docs/api/v1', function () { $path = public_path('api/v1.yml'); abort_unless(is_file($path), 404); // Bewusst als benannter Download: Browser zeigen YAML ohnehin nicht an, // und „v1.yml“ wäre als Dateiname im Download-Ordner nichtssagend. return response((string) file_get_contents($path), 200, [ 'Content-Type' => 'application/yaml; charset=UTF-8', 'Content-Disposition' => 'attachment; filename=pressekonto-api-v1.yml', ]); })->name('docs.api.v1');