presseportale/routes/web.php
Kevin Adametz 375138679d web: Erstquelle-Absatz in der Veröffentlichungsmail, vierte Transparenzseite „kostenlos veröffentlichen" (Anhang 1: 1d, 2.4, E7)
Meldungen, die über die API eingegangen sind, bekommen in der
Veröffentlichungsmail einen Absatz zum Unterschied Erstquelle/Kopie —
technische Tatsache, ohne Preis und Angebot; direkt eingereichte nicht.

/pressemitteilung-kostenlos-veroeffentlichen beantwortet die Suche nach
„kostenlos" ehrlich: kein kostenloser Weg (E7), was kostenlose Portale
leisten, warum Links aus Presseportalen kaum tragen (auch bei uns
sponsored nofollow), warum Annahme ohne Gegenwert und Prüfung sich
ausschließen, wer am Ende zahlt. Die Liste kostenloser Portale ist in
config/transparency.php vorbereitet und bleibt leer, bis geprüfte
Einträge mit Prüfdatum vorliegen.

Unter-Navigation, Hilfe, Sitemaps, llms.txt und die Modelltabelle der
Kostenseite verlinken die neue Seite. Anhang 1 und Protokoll Teil 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:22:05 +00:00

364 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');
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');
// 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');