newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)
Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
abfcd774e0
commit
9143cc4a54
15 changed files with 665 additions and 37 deletions
126
app/Http/Controllers/Web/NewsroomClaimController.php
Normal file
126
app/Http/Controllers/Web/NewsroomClaimController.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Company;
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
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('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());
|
||||
|
||||
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue