E9: Kategorie-Zuordnung wie vorgeschlagen übernommen, Zuordnung ist standardmäßig an (CANONICAL_ASSIGNMENT_ENABLED). Auf der Dev-Kopie verifiziert: Kopie trägt rel=canonical auf die Erstquelle und fällt aus der Sitemap ihres Portals. E10: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten und die API-Doku lagen auf allen drei Hosts mit identischem Text und je eigenem Self-Canonical. Jetzt führt presseecho die Informationsseiten, der Hub die API-Doku; Sitemap und llms.txt nennen nur die führende Adresse. Rechtstexte (Impressum, Datenschutz, AGB, Richtlinien, Meldeweg, Kündigung) bleiben bewusst je Host — jede Domain ist ein eigenes Angebot mit eigener Anbieterkennzeichnung. Bei der Erreichbarkeitsprüfung des Hubs gefunden und behoben: /de/preise, /de/veroeffentlichen, /de/newsrooms und /de/suche wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert (der Portal-Kontext fällt dort darauf zurück) — jetzt 301 auf presseecho, die Transparenz-Navigation verlinkt direkt dorthin. /de/kontakt und /de/ueber-uns sprangen über /de auf die Startseite, jetzt in einem Schritt. Der Self-Canonical kommt jetzt aus dem aufgerufenen Host; Portal-Inhalte, die über den Hub laufen, behalten ihr Portal (webPortalContextApplied). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
371 lines
18 KiB
PHP
371 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Http\Controllers\PressReleasePreviewController;
|
|
use App\Http\Controllers\Web\CategoryArchiveController;
|
|
use App\Http\Controllers\Web\CategoryController;
|
|
use App\Http\Controllers\Web\ContactController;
|
|
use App\Http\Controllers\Web\HomeController;
|
|
use App\Http\Controllers\Web\LegacyMediaRedirectController;
|
|
use App\Http\Controllers\Web\LlmsTxtController;
|
|
use App\Http\Controllers\Web\NewsroomClaimController;
|
|
use App\Http\Controllers\Web\NewsroomDetailController;
|
|
use App\Http\Controllers\Web\NewsroomsController;
|
|
use App\Http\Controllers\Web\NewsSitemapController;
|
|
use App\Http\Controllers\Web\PreiseController;
|
|
use App\Http\Controllers\Web\PressReleaseAttachmentMediaController;
|
|
use App\Http\Controllers\Web\PressReleaseImageMediaController;
|
|
use App\Http\Controllers\Web\PressReleaseReportController;
|
|
use App\Http\Controllers\Web\ReleaseDetailController;
|
|
use App\Http\Controllers\Web\ReleaseShareTrackingController;
|
|
use App\Http\Controllers\Web\ReportFormController;
|
|
use App\Http\Controllers\Web\SitemapController;
|
|
use App\Http\Controllers\Web\SucheController;
|
|
use App\Http\Controllers\Web\TerminationController;
|
|
use App\Http\Controllers\Web\TransparencyPageController;
|
|
use App\Http\Controllers\Web\VeroeffentlichenController;
|
|
use App\Http\Middleware\SetEdition;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\Plan;
|
|
use App\Scopes\PortalScope;
|
|
use App\Services\Web\HubPageData;
|
|
use App\Support\DomainAssetContext;
|
|
use Illuminate\Support\Facades\Route;
|
|
|
|
Route::get('/pm-vorschau/{token}', PressReleasePreviewController::class)
|
|
->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',
|
|
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen',
|
|
];
|
|
|
|
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');
|
|
// 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');
|
|
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');
|
|
|
|
// Transparenzseiten (Roadmap 2.4): Preis, ohne Abo, löschen/ändern.
|
|
Route::get('was-kostet-eine-pressemitteilung', [TransparencyPageController::class, 'cost'])->name('was-kostet-eine-pressemitteilung');
|
|
Route::get('pressemitteilung-veroeffentlichen-ohne-abo', [TransparencyPageController::class, 'noSubscription'])->name('pressemitteilung-veroeffentlichen-ohne-abo');
|
|
Route::get('pressemitteilung-loeschen-oder-aendern-lassen', [TransparencyPageController::class, 'changeOrDelete'])->name('pressemitteilung-loeschen-oder-aendern-lassen');
|
|
Route::get('pressemitteilung-kostenlos-veroeffentlichen', [TransparencyPageController::class, 'free'])->name('pressemitteilung-kostenlos-veroeffentlichen');
|
|
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');
|