Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
159 lines
5.9 KiB
PHP
159 lines
5.9 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);
|
|
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()->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();
|
|
}
|
|
}
|