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

@ -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 ============== --}}