presseportale/app/Http/Controllers/Web/NewsroomClaimController.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

166 lines
6.2 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Enums\ConversionSource;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Öffentliche Seite „Diesen Newsroom verwalten" (Roadmap 1b.2). Erreichbar
* für jede Firma mit Slug — auch ohne veröffentlichte Meldung, denn der
* Verwaltungszugang ist von der öffentlichen Sichtbarkeit unabhängig. Nur
* 410-entfernte Newsrooms bleiben Tombstone, aufgegangene führen zum
* Überlebenden.
*/
class NewsroomClaimController extends Controller
{
public function __construct(
private readonly NewsroomClaimService $claims,
private readonly MergedNewsroomLookup $mergedNewsrooms,
) {}
public function show(string $edition, string $slug): View|Response|RedirectResponse
{
$context = WebPortalContext::fromRequest();
$company = $this->claims->findClaimable($context->portalValues, $slug);
if (! $company) {
return $this->resolveMissing($context, $edition, $slug);
}
return view('web.newsroom-claim', [
'company' => $company,
'recipients' => $this->claims->maskedRecipients($company),
'unreachable' => $this->claims->isUnreachable($company),
]);
}
public function store(Request $request, string $edition, string $slug): RedirectResponse|Response
{
$context = WebPortalContext::fromRequest();
$company = $this->claims->findClaimable($context->portalValues, $slug);
if (! $company) {
return $this->resolveMissing($context, $edition, $slug);
}
// Bot: neutral abnicken, bewusst vor allem anderen.
if (filled($request->input('website'))) {
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
}
$this->ensureNotRateLimited($request, $company->getKey());
RateLimiter::hit($this->ipKey($request), 3600);
if ($this->claims->recipients($company)->isEmpty()) {
return back()->with($this->claims->isUnreachable($company) ? 'newsroom-claim-unreachable' : 'newsroom-claim-none', true);
}
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company));
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
// (Umsetzungsaudit 29.09.2026, R-09). Gegen Fluten bleibt die IP-
// Drossel oben.
if ($sent > 0) {
RateLimiter::hit($this->companyKey($company->getKey()), 86400);
}
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
}
/**
* Kam der Besucher aus der Veröffentlichungsmail einer Meldung (`pr` im
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
*/
private function landingPressReleaseId(Request $request, Company $company): ?int
{
$id = (int) $request->input('pr', 0);
if ($id <= 0) {
return null;
}
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null;
}
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse
{
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);
if ($survivor !== null && $survivor->gone_at === null) {
// Host aus dem Request wie bei den anderen 301-Weiterleitungen —
// route() allein trüge den konfigurierten Root-Host.
return redirect(
request()->getSchemeAndHttpHost().route('newsroom.claim', ['edition' => $edition, 'slug' => $survivor->slug], absolute: false),
301,
);
}
// 410-entfernt → Tombstone wie die Newsroom-Seite selbst; sonst 404.
$gone = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $context->portalValues)
->where('slug', $slug)
->whereNotNull('gone_at')
->exists();
if ($gone) {
return response()->view('web.newsroom-tombstone', [], 410);
}
abort(404);
}
/**
* Kam der Besucher aus einer Mail (`src` aus dem Link, per Formular
* weitergereicht), trägt das Token diese Herkunft — sonst ist es der
* Hinweis auf der Newsroom-Seite.
*/
private function sourceOf(Request $request): ConversionSource
{
$source = ConversionSource::parse($request->filled('src') ? (string) $request->input('src') : null);
return in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true)
? $source
: ConversionSource::ProfileClaim;
}
private function ensureNotRateLimited(Request $request, int $companyId): void
{
$perDay = (int) config('newsroom.claim.links_per_day', 3);
if (RateLimiter::tooManyAttempts($this->companyKey($companyId), $perDay)) {
throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_company')]);
}
if (RateLimiter::tooManyAttempts($this->ipKey($request), 15)) {
throw ValidationException::withMessages(['claim' => __('static.newsroom_claim.throttle_ip')]);
}
}
private function companyKey(int $companyId): string
{
return 'newsroom-claim|company|'.$companyId;
}
private function ipKey(Request $request): string
{
return 'newsroom-claim|ip|'.$request->ip();
}
}