images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)

- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit
  isPubliclyDeliverable()
- B-03: Livewire-Temp-Upload 16 MiB
- N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put()
  geprüft, alle Fassungen ins Archiv
- O03: published_ever_at in der Freigabetransaktion
  (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur
- B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre
- B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag;
  Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert
- B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme)
- B-08, B-09, B-11, B-12: published_ever_at nur bei Status published,
  Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale,
  Verarbeitungsfehler als Formularmeldung
- K-02-Teil: nach Kundenbestätigung erneut routen
- OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame

Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08
(Nachweis per POST), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:01 +00:00
parent 60650baeb8
commit f1c0e2766b
23 changed files with 1675 additions and 149 deletions

View file

@ -75,7 +75,7 @@ new #[Layout('components.layouts.app'), Title('Bilder')] class extends Component
match ($this->state) {
'review' => $query->awaitingPrecheckReview(),
'withheld' => $query->whereNotNull('withheld_at'),
'withheld' => $query->where(fn ($inner) => $inner->whereNotNull('withheld_at')->orWhereNotNull('precheck_withheld_at')),
'unconfirmed' => $query->whereNotNull('rules_version')->whereNull('rights_confirmed_at'),
'legacy' => $query->whereNull('rules_version'),
'published' => $query->whereNotNull('published_ever_at'),

View file

@ -9,6 +9,7 @@ use App\Enums\ImagePrecheckCategory;
use App\Services\Image\Precheck\ImagePrecheckReviewService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
use Flux\Flux;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
@ -29,12 +30,28 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
$this->id = $id;
}
/*
* Berechtigung (Audit K-06): jede Aktion, die Status oder Bild ändert,
* verlangt wie Liste und Bearbeiten `press-releases:publish`.
*/
public function publish(): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
try {
app(PressReleaseService::class)->publish($pr);
} catch (QuotaExceededException $e) {
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
Flux::modal('confirm-show-publish')->close();
return;
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-publish')->close();
return;
} catch (ImageRightsConfirmationRequiredException $e) {
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
Flux::modal('confirm-show-publish')->close();
@ -58,12 +75,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public function reject(): void
{
$this->authorize('press-releases:publish');
$this->validate([
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
]);
$pr = $this->findAdminPR($this->id);
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
try {
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-reject')->close();
return;
}
$this->rejectReason = '';
@ -73,8 +100,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public function archive(): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
app(PressReleaseService::class)->archive($pr);
try {
app(PressReleaseService::class)->archive($pr);
} catch (\LogicException $e) {
Flux::toast(text: $e->getMessage(), variant: 'danger');
Flux::modal('confirm-show-archive')->close();
return;
}
Flux::toast(text: __('Archiviert.'), variant: 'success');
Flux::modal('confirm-show-archive')->close();
}
@ -91,6 +128,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public function correctImage(ImageCorrectionService $corrections): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
@ -145,6 +184,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
private function decidePrecheck(ImagePrecheckReviewService $reviews, bool $approve): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->awaitingPrecheckReview()->orderByDesc('is_preview')->orderBy('sort_order')->first();
@ -170,6 +211,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
public function toggleWithheld(ImageCorrectionService $corrections): void
{
$this->authorize('press-releases:publish');
$pr = $this->findAdminPR($this->id);
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
@ -182,13 +225,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
);
$image->isWithheld()
// Nur der Rückhalt aus dem Korrekturweg (B-07): eine offene
// Vorab-Sichtung wird in der Sichtungs-Queue entschieden.
$image->isWithheldByCorrection()
? $corrections->release($image, auth()->user(), $this->correctionReason)
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
$this->reset(['correctionReason']);
Flux::toast(text: $image->fresh()->isWithheld() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
Flux::toast(text: $image->fresh()->isWithheldByCorrection() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
}
public function with(): array
@ -386,9 +431,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="approvePrecheck">{{ __('Bild freigeben') }}</flux:button>
<flux:button variant="danger" wire:click="rejectPrecheck" wire:confirm="{{ __('Bild wirklich ablehnen und entfernen?') }}">{{ __('Bild ablehnen') }}</flux:button>
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Bild-Sichtung nach Vorab-Check']) }}" target="_blank">
{{ __('Beweisoriginal öffnen') }}
</flux:button>
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
@csrf
<input type="hidden" name="purpose" value="Bild-Sichtung nach Vorab-Check">
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
</form>
</div>
</div>
</article>
@ -428,11 +476,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<div class="flex flex-wrap gap-2">
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
<flux:button variant="filled" wire:click="toggleWithheld">
{{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
</flux:button>
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Sichtprüfung im Admin']) }}" target="_blank">
{{ __('Beweisoriginal öffnen') }}
{{ $adminTitleImage->isWithheldByCorrection() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
</flux:button>
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
@csrf
<input type="hidden" name="purpose" value="Sichtprüfung im Admin">
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
</form>
</div>
</div>
</article>

View file

@ -146,7 +146,8 @@ new class extends Component {
// Vorab-Check (K.3): Ablehnung zeigt den Grund unter dem Dateifeld,
// alles andere bleibt als Hinweis oder wird beim Speichern übernommen.
try {
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease())?->toArray();
// Hash aus der Vorprüfung weiterreichen (OPT-4), nicht neu rechnen.
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease(), $result->fileSha256)?->toArray();
} catch (ImageUploadException $exception) {
$this->addError('newImage', $exception->getMessage());
$this->resetProvenance();
@ -283,6 +284,13 @@ new class extends Component {
} catch (ImageUploadException $exception) {
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
return;
} catch (\RuntimeException $exception) {
// Verarbeitungsfehler der Bildbibliothek (z. B. animiertes WebP,
// B-12): Hinweis am Dateifeld statt Serverfehler.
report($exception);
$this->addError('newImage', __('Das Bild konnte nicht verarbeitet werden. Bitte speichern Sie es als JPG oder PNG und laden Sie es erneut hoch.'));
return;
}
@ -340,7 +348,14 @@ new class extends Component {
return;
}
$remover->remove($image);
try {
// Status-Regel unter der Sperre erneut prüfen (B-05).
$remover->remove($image, auth()->user(), allowedStatuses: $this->allowedStatuses());
} catch (ImageUploadException $exception) {
Flux::toast(text: $exception->getMessage(), variant: 'danger');
return;
}
$this->dispatch('title-image-changed');
@ -475,12 +490,16 @@ new class extends Component {
* Status-Regel des handelnden Nutzers: Kunden ändern das Titelbild nur bei
* Entwürfen und abgelehnten PMs, Admins bei allem außer Archiv. Der
* Uploader prüft dieselbe Liste unter der Sperre erneut (Audit N07).
* Gekoppelt an actsAsAdmin() (B-09): Ein Admin, der selbst Eigentümer
* ist, lädt als Kunde hoch (sofort bestätigt) und bekommt deshalb auch
* die Kundenregel — sonst wäre ein Bild an einer veröffentlichten
* Meldung ohne Admin-Weg sofort öffentlich.
*
* @return list<PressReleaseStatus>
*/
private function allowedStatuses(): array
{
if (auth()->user()?->canAccessAdmin()) {
if ($this->actsAsAdmin()) {
return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected];
}

View file

@ -9,6 +9,7 @@ use App\Exceptions\InsufficientCreditsException;
use App\Models\BookingConfirmation;
use App\Models\LegalDocument;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\SinglePurchase;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Billing\CreditPricingService;
@ -121,6 +122,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
#[Locked]
public ?string $imageLegalTextKey = null;
/**
* Bild, das die Seite zur Bestätigung anzeigt (Umsetzungsaudit 29.09.,
* B-04): Kennung und Datei-Hash werden beim Rendern gesetzt und beim
* Bestätigen gegen das aktuelle Titelbild geprüft.
*/
#[Locked]
public ?int $confirmImageId = null;
#[Locked]
public ?string $confirmImageSha = null;
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
{
$pr = $this->getMyPR();
@ -134,6 +146,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
return;
}
// Bestätigt wird nur das Bild, das der Kunde gesehen hat (B-04).
if ($this->confirmImageId !== $image->id || $this->confirmImageSha === null || $image->original_sha256 === null || ! hash_equals($this->confirmImageSha, $image->original_sha256)) {
$this->imageRightsConfirmed = false;
$this->addError('imageRightsConfirmed', __('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild und bestätigen Sie erneut.'));
return;
}
$this->validate(
['imageRightsConfirmed' => ['accepted']],
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
@ -157,6 +177,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
request()->ip(),
request()->userAgent(),
route('agb', ['edition' => 'de']),
$this->confirmImageSha,
);
} catch (ImageUploadException $exception) {
$this->addError('imageRightsConfirmed', $exception->getMessage());
@ -169,6 +190,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
}
/**
* Angezeigtes Titelbild für die Bestätigung merken (B-04).
*/
private function rememberConfirmationImage(?PressReleaseImage $image): ?PressReleaseImage
{
$this->confirmImageId = $image?->id;
$this->confirmImageSha = $image?->original_sha256;
return $image;
}
public function mount(int $id): void
{
$this->id = $id;
@ -891,7 +923,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
'titleImage' => $titleImage = $this->rememberConfirmationImage($pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first()),
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('confirmImageRights', $pr),
'imageLegalShort' => $imageLegalText
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
@ -1671,6 +1703,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Nicht in der Sitemap — für Suchmaschinen ist die Erstfassung auf dem anderen Portal die maßgebliche Adresse (rel=canonical). Leser finden die Meldung weiterhin hier.') }}
@elseif ($visibility['in_sitemap_since'])
{{ __('Gelistet seit :date (:portals) — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y'), 'portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
@elseif (($visibility['sitemap_role'] ?? null) === 'source')
{{-- Fassung auch auf dem anderen Portal: Die Rolle kann gewechselt haben, ein Datum wäre geraten (N17). --}}
{{ __('In der Sitemap gelistet (:portals). Die Meldung liegt auch auf dem anderen Portal; seit wann diese Fassung die maßgebliche ist, lässt sich nicht genau sagen.', ['portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
@else
{{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }}
@endif
@ -1711,7 +1746,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird.') }}</p>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Zugeordnet laut Kennung des Abrufers (User-Agent), die sich nicht überprüfen lässt. Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Crawler-Abrufe zählen nicht als Aufrufe.') }}</p>
@else
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch kein Abruf protokolliert.') }}
@ -1851,7 +1886,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
:confirm-label="__('Zur Prüfung freigeben')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining"
:has-title-image="! $coverIsPlaceholder" />
:has-title-image="! $coverIsPlaceholder"
:legal-documents="$this->legalDocumentsForSubmit()" />
@endif
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}

View file

@ -120,7 +120,9 @@ new class extends Component
->with([
'company',
'category.translations' => fn ($query) => $query->where('locale', $this->language),
// Nur öffentlich auslieferbare Bilder (Audit 29.09., B-01).
'images' => fn ($query) => $query
->publiclyVisible()
->orderByDesc('is_preview')
->orderBy('sort_order')
->limit(1),