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); RateLimiter::hit($this->companyKey($company->getKey()), 86400); 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. $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company)); 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()->withoutGlobalScopes()->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(); } }