press-releases: Publish-Rechte, Quota-Races & Slug vereinheitlichen
- Publish/Reject/Archive und changeStatus erzwingen konsistent press-releases:publish; changeStatus schloss die Lücke, über die das Status-Modal die Prüfung umging (Fix#1/H2). - publish()/changeStatusFromAdmin sperren PM- und User-Zeile transaktional; QuotaExceededException wird in Admin-Publish und im Auto-Publish-Job abgefangen (Warnung statt 500 / bleibt in Review, Fix#3/M2). - Slug-Erzeugung nur noch über HasUniqueSlug (generateUniqueSlug + saveWithUniqueSlugRetry), redundanter Controller-Helper entfernt (M3). - API-/Legacy-Text wird vor der Persistenz sanitisiert (M6); Scheduler/Legacy-Tracking ignorieren soft-gelöschte PMs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7043ea8ad5
commit
0b35c3b04d
19 changed files with 536 additions and 91 deletions
|
|
@ -13,6 +13,7 @@ use App\Models\PressRelease;
|
|||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -40,20 +41,15 @@ class PressReleaseController extends Controller
|
|||
|
||||
public function store(StorePressReleaseRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$validated = $this->sanitizeHtmlText($request->validated());
|
||||
$company = $this->findOwnedCompany((int) $validated['company_id'], $request);
|
||||
abort_unless($company !== null, 403);
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScopes()->create([
|
||||
$pressRelease = new PressRelease([
|
||||
...$validated,
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $request->user()->id,
|
||||
'portal' => $company->portal->value,
|
||||
'slug' => $this->uniqueSlug(
|
||||
Str::slug($validated['title']),
|
||||
$company->portal->value,
|
||||
$validated['language'],
|
||||
),
|
||||
// Über die API angelegte PMs sind immer Entwürfe. Der Übergang nach
|
||||
// `review` erfolgt ausschließlich über die explizite submit-Route,
|
||||
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
|
||||
|
|
@ -68,6 +64,14 @@ class PressReleaseController extends Controller
|
|||
'ai_declaration_confirmed_at' => now(),
|
||||
] : []),
|
||||
]);
|
||||
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
|
||||
'portal' => $company->portal->value,
|
||||
'language' => $validated['language'],
|
||||
]);
|
||||
$pressRelease->saveWithUniqueSlugRetry($validated['title'], [
|
||||
'portal' => $company->portal->value,
|
||||
'language' => $validated['language'],
|
||||
]);
|
||||
|
||||
return PressReleaseResource::make(
|
||||
$pressRelease->load($this->apiResourceRelations())
|
||||
|
|
@ -96,7 +100,7 @@ class PressReleaseController extends Controller
|
|||
], 409);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$validated = $this->sanitizeHtmlText($request->validated());
|
||||
$company = isset($validated['company_id'])
|
||||
? $this->findOwnedCompany((int) $validated['company_id'], $request)
|
||||
: $this->findOwnedCompany((int) $pressRelease->company_id, $request);
|
||||
|
|
@ -116,10 +120,17 @@ class PressReleaseController extends Controller
|
|||
|| array_key_exists('language', $validated)
|
||||
|| array_key_exists('company_id', $validated)
|
||||
) {
|
||||
$pressRelease->slug = $this->uniqueSlug(Str::slug($title), $portal, $language, $pressRelease->id);
|
||||
// Selbst-Ausschluss übernimmt der Trait automatisch ($self->exists).
|
||||
$pressRelease->slug = $pressRelease->generateUniqueSlug($title, [
|
||||
'portal' => $portal,
|
||||
'language' => $language,
|
||||
]);
|
||||
}
|
||||
|
||||
$pressRelease->save();
|
||||
$pressRelease->saveWithUniqueSlugRetry($title, [
|
||||
'portal' => $portal,
|
||||
'language' => $language,
|
||||
]);
|
||||
|
||||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu prüfen.
|
||||
if ($pressRelease->wasChanged(['title', 'text'])) {
|
||||
|
|
@ -247,24 +258,24 @@ class PressReleaseController extends Controller
|
|||
return str_starts_with((string) $request->route()?->getName(), 'legacy-service.');
|
||||
}
|
||||
|
||||
private function uniqueSlug(string $baseSlug, string $portal, string $language, ?int $excludeId = null): string
|
||||
/**
|
||||
* Säubert einen per API übergebenen HTML-Text vor der Persistenz — analog
|
||||
* zum Web-Formular, das immer über den Sanitizer speichert. Nur HTML wird
|
||||
* durch HTMLPurifier gejagt; Legacy-Plaintext bleibt unverändert (wird beim
|
||||
* Rendern ohnehin escaped), damit `<`/`&` in Klartext-Einreichungen nicht
|
||||
* zerstört werden.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeHtmlText(array $validated): array
|
||||
{
|
||||
$slug = $baseSlug !== '' ? $baseSlug : Str::random(8);
|
||||
$candidate = $slug;
|
||||
$suffix = 2;
|
||||
$sanitizer = app(PressReleaseHtmlSanitizer::class);
|
||||
|
||||
while (
|
||||
PressRelease::withoutGlobalScopes()
|
||||
->where('portal', $portal)
|
||||
->where('language', $language)
|
||||
->where('slug', $candidate)
|
||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||
->exists()
|
||||
) {
|
||||
$candidate = "{$slug}-{$suffix}";
|
||||
$suffix++;
|
||||
if (array_key_exists('text', $validated) && $sanitizer->isHtml($validated['text'])) {
|
||||
$validated['text'] = $sanitizer->clean($validated['text']);
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
return $validated;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue