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

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
/**
* Umsetzungsaudit 29.09.2026, B-11: Beweisoriginale ohne Bildzeile (harter
* Abbruch nach dem Schreiben des Originals, z. B. memory_limit bei
* 8000 × 8000 px) nach einer Karenzzeit entfernen.
*
* Standard ist ein Trockenlauf. Dateien, auf die eine Bildzeile zeigt —
* auch weich gelöschte (Beweisarchiv ausstehend) —, bleiben immer liegen.
* Nach einem Datenbank-Restore fehlen Zeilen, deren Originale noch Beweis
* sind: Solange der Archiv-Sweep ausgesetzt ist (`archive:sweep-orphans
* --pause`, setzt auch `archive:reconcile-after-restore`), löscht auch dieser
* Befehl nichts.
*/
class SweepOrphanPressReleaseOriginals extends Command
{
protected $signature = 'press-release-images:sweep-orphan-originals
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
{--hours=48 : Nur Dateien, die älter als X Stunden sind}';
protected $description = 'Entfernt Beweisoriginale ohne zugehörige Bildzeile (B-11)';
public function handle(PressReleaseImageRemover $remover, EvidenceArchive $archive): int
{
$execute = (bool) $this->option('execute');
if ($execute && $archive->isSweepPaused()) {
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich) — nichts gelöscht. Fortsetzen mit archive:sweep-orphans --resume.');
return self::SUCCESS;
}
$result = $remover->sweepOrphanOriginals(
$execute,
(int) $this->option('hours'),
fn (string $file) => $this->line(' verwaist: '.$file),
);
$this->info(sprintf(
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
$execute ? 'Bereinigung' : 'Trockenlauf',
$result['scanned'],
$result['orphans'],
$result['removed'],
));
return $execute && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -118,7 +118,15 @@ class PressReleaseImageController extends Controller
}
if (config('images.rights_v2')) {
$this->remover->remove($image);
// Status-Regel unter der Sperre erneut prüfen (Umsetzungsaudit
// 29.09., B-05): Ein gleichzeitiges Einreichen gewinnt.
try {
$this->remover->remove($image, $request->user(), allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
} catch (ImageUploadException) {
return response()->json([
'message' => 'Only draft or rejected press releases may be edited.',
], 409);
}
} else {
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();

View file

@ -8,6 +8,8 @@ use App\Enums\CreditSource;
use App\Enums\ImageLicenseType;
use App\Enums\ImagePrecheckOutcome;
use App\Enums\ImageProvenanceStatus;
use App\Enums\PressReleaseStatus;
use App\Scopes\PortalScope;
use Database\Factories\PressReleaseImageFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -69,6 +71,7 @@ use Illuminate\Support\Facades\URL;
* @property Carbon|null $published_ever_at
* @property Carbon|null $customer_confirmation_requested_at
* @property Carbon|null $withheld_at
* @property Carbon|null $precheck_withheld_at
* @property string|null $rules_version
* @property int|null $focus_x
* @property int|null $focus_y
@ -132,6 +135,7 @@ class PressReleaseImage extends Model
'published_ever_at',
'customer_confirmation_requested_at',
'withheld_at',
'precheck_withheld_at',
'rules_version',
'focus_x',
'focus_y',
@ -168,6 +172,7 @@ class PressReleaseImage extends Model
'published_ever_at' => 'datetime',
'customer_confirmation_requested_at' => 'datetime',
'withheld_at' => 'datetime',
'precheck_withheld_at' => 'datetime',
'focus_x' => 'integer',
'focus_y' => 'integer',
'no_crop' => 'boolean',
@ -241,6 +246,34 @@ class PressReleaseImage extends Model
return $this->published_ever_at !== null;
}
/**
* Wird das Bild an einer veröffentlichten Meldung öffentlich
* auslieferbar (Kundenbestätigung, Freigabe der Vorab-Sichtung oder der
* Korrektur), gilt es ab diesem Moment als veröffentlicht (DU E5). Die
* Marke gehört in die Transaktion der Freigabe, nicht in den
* nachgelagerten Snapshot — sonst macht ein Schreibfehler dort aus einem
* öffentlich gezeigten Bild ein „nie veröffentlichtes", das beim
* Entfernen ohne Archiv sofort gelöscht würde (Umsetzungsaudit
* 02.10.2026, O03).
*/
public function markPublishedIfNowPublic(): void
{
if ($this->published_ever_at !== null || ! $this->isPubliclyDeliverable()) {
return;
}
$status = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($this->press_release_id)
->value('status');
if (($status instanceof PressReleaseStatus ? $status : PressReleaseStatus::tryFrom((string) $status)) !== PressReleaseStatus::Published) {
return;
}
$this->forceFill(['published_ever_at' => now()])->save();
}
/**
* Ausgang `queue` des Vorab-Checks (Block K): Bild ist zurückgehalten,
* bis ein Admin freigibt oder ablehnt.
@ -264,25 +297,43 @@ class PressReleaseImage extends Model
}
/**
* Vorläufig zurückgehalten (Korrekturweg E.6, Vorab-Check K.4):
* öffentlich greift der Platzhalter, im Panel bleibt das Bild sichtbar.
* Vorläufig zurückgehalten — aus irgendeinem Grund: Korrekturweg (E.6,
* `withheld_at`) oder offene Vorab-Sichtung (K.4, `precheck_withheld_at`).
* Öffentlich greift der Platzhalter, im Panel bleibt das Bild sichtbar.
*/
public function isWithheld(): bool
{
return $this->withheld_at !== null || $this->precheck_withheld_at !== null;
}
/**
* Rückhalt aus dem Korrekturweg (E.6). Die beiden Gründe sind getrennt
* (Umsetzungsaudit 29.09.2026, B-07): Die Freigabe der Vorab-Sichtung
* hebt einen Rückhalt wegen einer Beschwerde nicht auf und umgekehrt.
*/
public function isWithheldByCorrection(): bool
{
return $this->withheld_at !== null;
}
public function isWithheldForPrecheck(): bool
{
return $this->precheck_withheld_at !== null;
}
/**
* Gemeinsamer Freigabezustand für die öffentliche Auslieferung (Audit
* 25.09. N01): nicht zurückgehalten und — für Bilder aus Formular v2 —
* mit Rechtebestätigung (ein Admin-Upload wartet bis zum Kundenklick,
* DU E10). Der Bestand (`rules_version` NULL) hat kein Protokoll und
* bleibt ausdrücklich sichtbar. Seitenabfragen, OG-Auswahl und
* 25.09. N01): kein Rückhalt (Korrektur oder Vorab-Sichtung, B-07) und —
* für Bilder aus Formular v2 — mit Rechtebestätigung (ein Admin-Upload
* wartet bis zum Kundenklick, DU E10). Der Bestand (`rules_version`
* NULL) hat kein Protokoll und bleibt ausdrücklich sichtbar.
* Seitenabfragen, Teaser, OG-Auswahl, Nachweis-PDF, Snapshot und
* Medienroute wenden dieselbe Regel an; der Scope ist gleichlautend.
*/
public function isPubliclyDeliverable(): bool
{
return $this->withheld_at === null
&& $this->precheck_withheld_at === null
&& ($this->rules_version === null || $this->rights_confirmed_at !== null);
}
@ -296,6 +347,7 @@ class PressReleaseImage extends Model
{
return $query
->whereNull('withheld_at')
->whereNull('precheck_withheld_at')
->where(fn (Builder $inner) => $inner
->whereNull('rules_version')
->orWhereNotNull('rights_confirmed_at'));

View file

@ -139,8 +139,12 @@ class ImageProvenanceInspector
}
/**
* Öffnet die Datei einmal mit Imagick und liest die eingebetteten
* IPTC- und XMP-Profile. Wirft, wenn die Datei kein lesbares Bild ist.
* Öffnet die Datei mit Imagick und liest die eingebetteten IPTC- und
* XMP-Profile. Wirft, wenn die Datei kein lesbares Bild ist.
*
* OPT-3 (Umsetzungsaudit 29.09.): JPEG nur „anpingen" (Kopf und
* Profile ohne Pixeldekodierung). Die PNG- und WebP-Coder liefern beim
* Ping kein XMP (mit Fixtures geprüft) — dort weiter voll lesen.
*
* @return array{iptc: string|null, xmp: string|null}
*/
@ -153,7 +157,13 @@ class ImageProvenanceInspector
$imagick = new Imagick;
try {
$imagick->readImage($absolutePath);
$imagick->pingImage($absolutePath);
if (strtoupper($imagick->getImageFormat()) !== 'JPEG') {
$imagick->clear();
$imagick->readImage($absolutePath);
}
$profiles = $imagick->getImageProfiles('*', true);
} finally {
$imagick->clear();

View file

@ -4,12 +4,15 @@ namespace App\Services\Image\Precheck;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\PressReleaseStatus;
use App\Models\KiAudit;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Admin-Queue des Vorab-Checks (DU 12.3, 12.5): Ein Bild mit Ausgang
@ -19,7 +22,10 @@ use Illuminate\Support\Facades\DB;
*/
class ImagePrecheckReviewService
{
public function __construct(private readonly PressReleaseImageRemover $remover) {}
public function __construct(
private readonly PressReleaseImageRemover $remover,
private readonly PublishedImageSnapshot $snapshots,
) {}
public function approve(PressReleaseImage $image, User $admin, string $note): void
{
@ -28,14 +34,33 @@ class ImagePrecheckReviewService
'precheck_reviewed_at' => now(),
'precheck_reviewed_by' => $admin->getKey(),
'precheck_review_note' => $note,
'withheld_at' => null,
// Nur der eigene Grund fällt (Umsetzungsaudit 29.09., B-07):
// ein Rückhalt aus dem Korrekturweg bleibt bestehen.
'precheck_withheld_at' => null,
])->save();
$image->markPublishedIfNowPublic();
$this->audit($image, 'approved', $note);
});
if ($image->pressRelease?->published_at !== null) {
WebCaches::forgetPublicPages();
$pressRelease = $image->pressRelease;
if ($pressRelease?->status === PressReleaseStatus::Published && $image->isPubliclyDeliverable()) {
// Jetzt erst öffentlich: Veröffentlichungssnapshot (N09) nach
// dem N08-Muster — Fehler werden gemeldet, nicht geworfen.
try {
$this->snapshots->capture($image, $pressRelease);
} catch (Throwable $exception) {
report($exception);
}
}
if ($pressRelease?->published_at !== null) {
try {
WebCaches::forgetPublicPages();
} catch (Throwable $exception) {
report($exception);
}
}
}

View file

@ -24,17 +24,21 @@ class PrecheckImagePreparer
$maxEdge = max(256, (int) config('images.precheck.max_edge', 1024));
$quality = (int) config('images.precheck.jpeg_quality', 82);
$imagick = new Imagick;
$source = new Imagick;
$imagick = $source;
try {
$imagick->readImage($absolutePath);
$imagick->setIteratorIndex(0);
// Nur das erste Bild lesen (OPT-5): Animationen und Mehrseiter
// werden nicht vollständig dekodiert.
$source->readImage($absolutePath.'[0]');
$source->setIteratorIndex(0);
$imagick->autoOrient();
$source->autoOrient();
$imagick->setImageBackgroundColor('white');
$imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
$imagick = $imagick->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
$source->setImageBackgroundColor('white');
$source->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
$imagick = $source->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
$source->clear();
$imagick->stripImage();
$width = $imagick->getImageWidth();
@ -51,6 +55,7 @@ class PrecheckImagePreparer
$bytes = $imagick->getImageBlob();
} finally {
$imagick->clear();
$source->clear();
}
if ($bytes === '') {

View file

@ -4,12 +4,19 @@ namespace App\Services\PressRelease\Images;
use App\Enums\ConfirmationChannel;
use App\Enums\ConversionSource;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\StaleClassificationException;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Kundenbestätigung für Admin-Uploads (Decision-Update E10, Block B.4):
@ -24,11 +31,16 @@ class CustomerImageConfirmation
public function __construct(
private readonly MagicLinkGenerator $magicLinks,
private readonly PressReleaseImageUploader $uploader,
private readonly PressReleaseService $pressReleases,
) {}
/**
* Link erzeugen und Mail an den Kontoinhaber schicken. Erneut aufrufbar;
* ein neuer Link entwertet den alten (MagicLinkGenerator).
* ein neuer Link entwertet nur den vorherigen Link für dieses Bild. Der
* Link hat einen eigenen Zweck (B-10): Spätere Login-Links entwerten
* ihn nicht. Gültigkeit bleibt bei 72 Stunden — der Kunde muss die
* Mail nicht sofort lesen; der Link ist einmalig und meldet nur den
* Kontoinhaber an, der ohnehin jederzeit einen Login-Link anfordern kann.
*/
public function request(PressReleaseImage $image, ?string $requestedIp = null): bool
{
@ -39,16 +51,16 @@ class CustomerImageConfirmation
return false;
}
$link = $this->magicLinks->createLoginLink(
$link = $this->magicLinks->createImageConfirmationLink(
$owner,
$requestedIp,
self::LINK_TTL_MINUTES,
ConversionSource::MailImageConfirmation,
(int) $image->id,
[
'company_id' => $pressRelease->company_id,
'press_release_id' => $pressRelease->id,
'image_confirmation' => $image->id,
],
$requestedIp,
self::LINK_TTL_MINUTES,
ConversionSource::MailImageConfirmation,
);
if ($image->customer_confirmation_requested_at === null) {
@ -70,14 +82,50 @@ class CustomerImageConfirmation
/**
* Bestätigung des Kontoinhabers im Konto (Kanal magic_link).
* `$expectedSha256` ist der Hash des angezeigten Bildes (B-04).
*/
public function confirm(PressReleaseImage $image, User $customer, ?string $legalTextKey, ?string $ip, ?string $userAgent, ?string $agbUrl = null): ImageRightsConfirmation
public function confirm(PressReleaseImage $image, User $customer, ?string $legalTextKey, ?string $ip, ?string $userAgent, ?string $agbUrl = null, ?string $expectedSha256 = null): ImageRightsConfirmation
{
return $this->uploader->confirmExisting(
$confirmation = $this->uploader->confirmExisting(
$image,
new ImageUploadActor($customer, ConfirmationChannel::MagicLink, null, $ip, $userAgent),
$legalTextKey,
$agbUrl,
$expectedSha256,
);
$this->resumeRouting($image->pressRelease()->withoutGlobalScope(PortalScope::class)->first());
return $confirmation;
}
/**
* Umsetzungsaudit 29.09.2026, K-02: Die KI hat die Meldung freigegeben
* (Grün, oder Gelb ohne Queue-Schalter), die Veröffentlichung scheiterte
* aber am ausstehenden Bild. Nach der Bestätigung dieselbe Weiche erneut
* durchlaufen — nur, solange nichts anderes entscheidet: Status
* `review`, keine manuelle Prüfung, kein Termin in der Zukunft (den
* übernimmt der Scheduler). Die Bestätigung selbst bleibt gültig, auch
* wenn das Routing scheitert.
*/
private function resumeRouting(?PressRelease $pressRelease): void
{
if (
$pressRelease === null
|| $pressRelease->status !== PressReleaseStatus::Review
|| ! in_array($pressRelease->classification, [PressReleaseClassification::Green, PressReleaseClassification::Yellow], true)
|| $pressRelease->requiresManualReview()
|| ($pressRelease->scheduled_at !== null && $pressRelease->scheduled_at->isFuture())
) {
return;
}
try {
$this->pressReleases->routeByClassification($pressRelease, $pressRelease->classification);
} catch (StaleClassificationException) {
// Inhalt geändert: die Prüfung mit Routing ist neu eingeplant.
} catch (Throwable $exception) {
report($exception);
}
}
}

View file

@ -4,14 +4,20 @@ namespace App\Services\PressRelease\Images;
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\PressReleaseStatus;
use App\Models\ArchivePendingJob;
use App\Models\ImageDeclarationCorrection;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Korrekturweg (Bildkennzeichnung E.6, Decision-Update S17): Der Admin
@ -25,6 +31,7 @@ class ImageCorrectionService
private readonly PublishedImageSnapshot $snapshots,
private readonly PressReleaseArchiver $archiver,
private readonly ImageService $imageService,
private readonly ArchivePendingQueue $pendingArchive,
) {}
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
@ -81,10 +88,18 @@ class ImageCorrectionService
private function apply(PressReleaseImage $image, User $admin, string $field, ?string $old, ?string $new, ?string $oldSource, string $reason, \Closure $mutation): ImageDeclarationCorrection
{
$correction = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation): ImageDeclarationCorrection {
$mutation();
// Nie veröffentlicht: kein Archiveintrag (DU 4.1, Umsetzungsaudit
// 5.1 b) — die Korrekturzeile dokumentiert den Vorgang; wird das Bild
// später veröffentlicht und entfernt, wandern alle Korrekturen mit
// dem image_removed-Eintrag ins Archiv.
$archive = $image->wasEverPublished();
return ImageDeclarationCorrection::query()->create([
/** @var array{0: ImageDeclarationCorrection, 1: ArchivePendingJob|null} $result */
$result = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation, $archive): array {
$mutation();
$image->markPublishedIfNowPublic();
$correction = ImageDeclarationCorrection::query()->create([
'press_release_image_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'admin_id' => $admin->getKey(),
@ -95,25 +110,50 @@ class ImageCorrectionService
'reason' => $reason,
'created_at' => now(),
]);
// Auftrag „Archivierung steht aus" in derselben Transaktion
// (Umsetzungsaudit 29.09., B-06/A-09): archive:retry-pending holt
// label_corrected nach, wenn die Ablage unten scheitert.
$job = $archive
? $this->pendingArchive->push((int) $image->press_release_id, ArchiveEvent::LabelCorrected, $correction, ArchiveReasonCategory::Correction, actorId: (int) $admin->getKey())
: null;
return [$correction, $job];
});
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
// Seiten-Caches verwerfen (D.5). Jede Korrektur zusätzlich als
// Archiveintrag label_corrected (Block F.4).
if (in_array($field, [ImageDeclarationCorrection::FIELD_CREDIT, ImageDeclarationCorrection::FIELD_AI_DECLARATION], true)) {
$this->refreshOgVariant($image->fresh());
[$correction, $job] = $result;
$fresh = $image->fresh() ?? $image;
// Zuerst der Archiveintrag (Block F.4); completeWith() wirft nie und
// lässt den Auftrag bei einem Fehler offen.
if ($job !== null) {
$this->pendingArchive->completeWith($job, fn () => $this->archiver->labelCorrected($fresh, $correction, $admin));
}
// Nie veröffentlicht: kein Archiveintrag (DU 4.1, Umsetzungsaudit
// 5.1 b) — die Korrekturzeile dokumentiert den Vorgang; wird das Bild
// später veröffentlicht und entfernt, wandern alle Korrekturen mit
// dem image_removed-Eintrag ins Archiv.
if ($image->wasEverPublished()) {
$this->snapshots->capture($image->fresh());
WebCaches::forgetPublicPages();
$this->archiver->labelCorrected($image->fresh(), $correction, $admin);
// Nacharbeiten nach dem N08-Muster: ein Fehler wird gemeldet und
// kostet weder die Korrektur noch den Archiveintrag.
if (in_array($field, [ImageDeclarationCorrection::FIELD_CREDIT, ImageDeclarationCorrection::FIELD_AI_DECLARATION], true)) {
$this->quietly(fn () => $this->refreshOgVariant($fresh));
}
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen (neue
// Fassung, N09) und Seiten-Caches verwerfen (D.5). Auch für ein an
// einer veröffentlichten Meldung freigegebenes Bild, das damit erst
// öffentlich wird.
if ($archive || $fresh->pressRelease?->status === PressReleaseStatus::Published) {
$this->quietly(fn () => $this->snapshots->capture($fresh->fresh() ?? $fresh));
$this->quietly(fn () => WebCaches::forgetPublicPages());
}
return $correction;
}
private function quietly(\Closure $step): void
{
try {
$step();
} catch (Throwable $exception) {
report($exception);
}
}
}

View file

@ -4,10 +4,15 @@ namespace App\Services\PressRelease\Images;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\PressReleaseStatus;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\FileDeletion;
@ -15,6 +20,7 @@ use App\Support\WebCaches;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
@ -22,9 +28,10 @@ use Illuminate\Support\Facades\Storage;
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
* und vollständig weg, die Zeile wird hart gelöscht — schlägt eine
* Dateilöschung fehl, bleibt die Zeile für den nächsten Versuch (5.1 e);
* - veröffentlicht: Original, Snapshot und Bestätigung ins Beweisarchiv
* (Block F), danach die lokalen Beweisdateien weg und die Pfade an der
* Zeile geleert; Derivate weg, Zeile weich gelöscht.
* - veröffentlicht: Zeile weich gelöscht und Derivate weg, Auftrag im
* Archiv-Nachholer; Original, alle Snapshot-Fassungen und Bestätigung ins
* Beweisarchiv (Block F), erst danach die lokalen Beweisdateien weg und
* die Pfade an der Zeile geleert.
*
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
* wie bisher behandelt (Derivate weg, Soft Delete).
@ -34,39 +41,103 @@ class PressReleaseImageRemover
public function __construct(
private readonly ImageService $imageService,
private readonly PressReleaseArchiver $archiver,
private readonly ArchivePendingQueue $pendingArchive,
) {}
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null): void
/**
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
* Rejected, Admin: alles außer Archived). Meldung und Bild werden unter
* Sperre neu geladen, Status-Regel und `published_ever_at` erneut
* geprüft (Umsetzungsaudit 29.09., B-05, analog N07): Ein gleichzeitiges
* Veröffentlichen kann so kein öffentlich gewesenes Bild in die
* Sofortlöschung schicken. Null = keine Regel (System- und Admin-Wege,
* die den Status selbst verantworten).
*
* @param list<PressReleaseStatus>|null $allowedStatuses
*
* @throws ImageUploadException Status der Meldung passt nicht mehr.
*/
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?array $allowedStatuses = null): void
{
if ($image->wasEverPublished() || $image->isLegacyRules()) {
// Veröffentlicht: erst der Archiveintrag, dann verschwinden die
// Derivate — ein Archivfehler lässt das Bild unangetastet.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
$this->archiveTrashed($image, $actor, $event, $reason, $legalRequest);
/** @var array{disk: string, path: string, variants: array<string, string>|null, public: bool, job: ArchivePendingJob|null}|null $derivatives */
$derivatives = DB::transaction(function () use ($image, $actor, $event, $reason, $legalRequest, $allowedStatuses): ?array {
$pressRelease = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($image->press_release_id)
->lockForUpdate()
->first();
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
if ($locked === null) {
return null;
}
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
WebCaches::forgetPublicPages();
if ($allowedStatuses !== null && ($pressRelease === null || ! in_array($pressRelease->status, $allowedStatuses, true))) {
throw new ImageUploadException(__('Der Status der Meldung hat sich geändert. Bitte laden Sie die Seite neu.'));
}
return;
}
// Die Instanz des Aufrufers auf den gesperrten Stand bringen:
// entschieden wird nach dem, was jetzt in der Datenbank steht.
$image->setRawAttributes($locked->getAttributes(), true);
$derivatives = ['disk' => $image->disk, 'path' => $image->path, 'variants' => $image->variants, 'public' => false, 'job' => null];
if ($image->wasEverPublished() || $image->isLegacyRules()) {
// Veröffentlicht: Auftrag „Archivierung steht aus" in derselben
// Transaktion wie das Ausblenden (E15, Umsetzungsaudit 29.09.).
// Der Eintrag folgt nach dem Commit; scheitert er oder fehlt
// der Schlüssel, bleibt der Auftrag offen und die lokalen
// Beweisdateien bleiben liegen, bis archive:retry-pending ihn
// schreibt.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
$derivatives['job'] = $this->pendingArchive->push(
(int) $image->press_release_id,
$event,
$image,
$reason,
$legalRequest?->getKey(),
$actor !== null ? (int) $actor->getKey() : null,
);
}
$image->delete();
return ['public' => true] + $derivatives;
}
DB::transaction(function () use ($image): void {
foreach ($image->rightsConfirmations()->get() as $confirmation) {
$confirmation->purgeForUnpublished();
}
$disk = $this->evidenceDisk($image);
foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) {
foreach (array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)]))) as $path) {
FileDeletion::deleteOrThrow($disk, $path, 'Sofortlöschung Bild #'.$image->getKey());
}
$image->forceDelete();
return $derivatives;
});
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
if ($derivatives === null) {
return;
}
if ($derivatives['job'] !== null) {
$this->pendingArchive->completeWith($derivatives['job'], fn () => $this->archiveTrashed($image, $actor, $event, $reason, $legalRequest));
}
$this->imageService->deletePressReleaseImage($derivatives['disk'], $derivatives['path'], $derivatives['variants']);
if ($derivatives['public']) {
try {
WebCaches::forgetPublicPages();
} catch (Throwable $exception) {
report($exception);
}
}
}
/**
@ -94,15 +165,8 @@ class PressReleaseImageRemover
private function deleteEvidenceFiles(PressReleaseImage $image): void
{
$disk = $this->evidenceDisk($image);
$paths = array_values(array_filter([$image->original_path, $image->published_snapshot_path]));
if ($image->published_snapshot_path !== null) {
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
if (is_string($sidecar)) {
$paths[] = $sidecar;
}
}
// Alle Snapshot-Fassungen (N09) liegen jetzt im Archiv.
$paths = array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)])));
$remaining = FileDeletion::deleteOrLog($disk, $paths, 'Beweisdateien Bild #'.$image->getKey());
@ -128,6 +192,50 @@ class PressReleaseImageRemover
->count();
}
/**
* Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09., B-11): Der
* Uploader schreibt das Original vor der Verarbeitung; ein harter
* Abbruch (Fatal Error) hinterlässt eine Datei ohne Zeile. Dateien,
* auf die eine Zeile zeigt — auch weich gelöschte —, bleiben liegen.
* Die Pfade bleiben hier gekapselt (E.4).
*
* @param (callable(string): void)|null $report erhält jeden verwaisten Pfad
* @return array{scanned: int, orphans: int, removed: int}
*/
public function sweepOrphanOriginals(bool $execute, int $olderThanHours, ?callable $report = null): array
{
$disk = Storage::disk((string) config('images.originals_disk', 'local'));
$threshold = now()->subHours(max(1, $olderThanHours))->getTimestamp();
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
foreach (array_chunk($disk->allFiles((string) config('images.originals_directory')), 500) as $chunk) {
$result['scanned'] += count($chunk);
$known = PressReleaseImage::withTrashed()
->whereIn('original_path', $chunk)
->pluck('original_path')
->all();
foreach (array_diff($chunk, $known) as $file) {
if ($disk->lastModified($file) > $threshold) {
continue;
}
$result['orphans']++;
if ($report !== null) {
$report($file);
}
if ($execute && FileDeletion::delete($disk, $file)) {
$result['removed']++;
}
}
}
return $result;
}
private function evidenceDisk(PressReleaseImage $image): Filesystem
{
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));

View file

@ -76,9 +76,10 @@ class PressReleaseImageUploader
* einem Satz und wird an der PM protokolliert; alles andere kommt als
* Ergebnis zurück, das `store()` über den Hash wiederverwendet.
*/
public function precheck(UploadedFile $upload, PressRelease $pressRelease): ?ImagePrecheckResult
public function precheck(UploadedFile $upload, PressRelease $pressRelease, ?string $sha256 = null): ?ImagePrecheckResult
{
$result = $this->precheck->run($upload->getRealPath(), (string) hash_file('sha256', $upload->getRealPath()));
// Den Hash aus preflight() weiterreichen statt neu zu rechnen (OPT-4).
$result = $this->precheck->run($upload->getRealPath(), $sha256 ?? (string) hash_file('sha256', $upload->getRealPath()));
if ($result === null) {
return null;
@ -146,6 +147,7 @@ class PressReleaseImageUploader
}
$stored = null;
$publishedNow = false;
try {
$provenance = $this->inspector->inspect($disk->path($originalPath));
@ -164,7 +166,7 @@ class PressReleaseImageUploader
ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()),
);
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation, $allowedStatuses): PressReleaseImage {
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective, $precheck, $precheckEvaluation, $allowedStatuses, &$publishedNow): PressReleaseImage {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
@ -179,6 +181,13 @@ class PressReleaseImageUploader
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
}
// Sofort öffentlich ist das Bild nur an einer veröffentlichten
// Meldung, mit Bestätigung und ohne Rückhalt der Vorab-Sichtung
// (B-08, N09). Ein Admin-Upload wird es erst mit dem Kundenklick.
$publishedNow = $locked->status === PressReleaseStatus::Published
&& $actor->confirmsImmediately()
&& $precheckEvaluation?->outcome !== ImagePrecheckOutcome::Queue;
$image = $locked->images()->create([
'disk' => $stored['disk'],
'path' => $stored['path'],
@ -201,11 +210,11 @@ class PressReleaseImageUploader
'precheck' => $precheckEvaluation !== null ? $this->precheck->storedPayload($precheck, $precheckEvaluation) : null,
'precheck_outcome' => $precheckEvaluation?->outcome,
'precheck_review_required_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
'withheld_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
'precheck_withheld_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
'original_disk' => $originalsDisk,
'original_path' => $originalPath,
'original_sha256' => $sha256,
'published_ever_at' => $locked->published_at !== null ? now() : null,
'published_ever_at' => $publishedNow ? now() : null,
'customer_confirmation_requested_at' => $actor->confirmsImmediately() ? null : now(),
'rules_version' => config('images.rules_version'),
'focus_x' => $submission->focus['x'] ?? null,
@ -242,6 +251,12 @@ class PressReleaseImageUploader
throw $exception;
}
// Sofort öffentlich an einer veröffentlichten Meldung (z. B. Admin,
// der selbst Eigentümer ist, B-09): Snapshot wie beim Veröffentlichen.
if ($publishedNow) {
$this->captureSnapshotQuietly($image, $pressRelease);
}
// Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon
// öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar.
if ($pressRelease->published_at !== null) {
@ -257,8 +272,13 @@ class PressReleaseImageUploader
* der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash.
* Zusätzlich zur Policy prüft der Service selbst, dass der Bestätiger
* der Kontoinhaber ist und eine Bestätigung aussteht (Audit N05).
*
* Das Bild wird unter Sperre neu geladen (B-14: ein Doppelklick ergibt
* genau eine Bestätigung, ein inzwischen entferntes Bild keine).
* `$expectedSha256` bindet die Bestätigung an die Datei, die dem Kunden
* angezeigt wurde (B-04).
*/
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null, ?string $expectedSha256 = null): ImageRightsConfirmation
{
$pressRelease = $image->pressRelease;
@ -271,35 +291,51 @@ class PressReleaseImageUploader
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
}
// Frisch prüfen, nicht aus einer eventuell veralteten Relation:
// keine Doppelbestätigung, kein Bestätigen eines ersetzten Bildes.
$image->unsetRelation('latestRightsConfirmation');
if (! $image->awaitsCustomerConfirmation()) {
throw new ImageUploadException(__('Für dieses Bild steht keine Bestätigung aus.'), 'rights_confirmed');
}
$legalText = $this->resolveLegalText(
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
$actor,
);
if ($legalText === null || $image->original_sha256 === null) {
if ($legalText === null) {
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
}
$confirmation = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
$confirmation = $this->recordConfirmation($image, $pressRelease, $actor, $legalText, $agbUrl);
$image->forceFill(['rights_confirmed_at' => now()])->save();
[$confirmation, $image] = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl, $expectedSha256): array {
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
return $confirmation;
if ($locked === null) {
throw new ImageUploadException(__('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild.'), 'rights_confirmed');
}
if ($expectedSha256 !== null && ($locked->original_sha256 === null || ! hash_equals($expectedSha256, $locked->original_sha256))) {
throw new ImageUploadException(__('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild.'), 'rights_confirmed');
}
// Frisch unter der Sperre prüfen: keine Doppelbestätigung, kein
// Bestätigen eines ersetzten Bildes.
if (! $locked->awaitsCustomerConfirmation()) {
throw new ImageUploadException(__('Für dieses Bild steht keine Bestätigung aus.'), 'rights_confirmed');
}
if ($locked->original_sha256 === null) {
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
}
$confirmation = $this->recordConfirmation($locked, $pressRelease, $actor, $legalText, $agbUrl);
$locked->forceFill(['rights_confirmed_at' => now()])->save();
$locked->markPublishedIfNowPublic();
return [$confirmation, $locked];
});
// Admin-Bildtausch an einer veröffentlichten PM: Mit der Bestätigung
// wird das Bild öffentlich — Veröffentlichungssnapshot (E.2) und
// Seiten-Caches folgen hier, nicht beim Upload.
if ($pressRelease->published_at !== null) {
if ($pressRelease->status === PressReleaseStatus::Published) {
$this->captureSnapshotQuietly($image, $pressRelease);
}
if ($pressRelease->published_at !== null) {
$this->forgetPublicPagesQuietly();
}

View file

@ -2,34 +2,66 @@
namespace App\Services\PressRelease\Images;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Support\ImageCredit;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
/**
* Snapshot der veröffentlichten Bildfassung (Bildkennzeichnung E.2,
* Decision-Update E2/S1): beim Veröffentlichen wird die ausgelieferte
* Cover-Variante zusammen mit dem gerenderten Nachweis und der Kennzeichnung
* auf den privaten Snapshot-Pfad kopiert. So lässt sich später belegen, was
* mit welchem Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
* Cover-Variante zusammen mit der OG-Variante (eingebrannter Nachweis),
* dem gerenderten Nachweis und der Kennzeichnung auf den privaten
* Snapshot-Pfad kopiert. So lässt sich später belegen, was mit welchem
* Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
*
* Versioniert (Umsetzungsaudit 29.09.2026, N09): Der Pfad enthält den Hash
* der Cover-Bytes UND den Hash der öffentlichen Angaben (Nachweis,
* Kennzeichnung, Bestätigung, OG-Bytes). Jede Korrektur erzeugt eine neue
* Fassung, frühere bleiben liegen und wandern beim Entfernen mit ins
* Beweisarchiv (versionPaths()). Erfasst werden nur öffentlich auslieferbare
* Bilder; nur sie erhalten `published_ever_at`.
*/
class PublishedImageSnapshot
{
/**
* Alle öffentlich auslieferbaren v2-Bilder einer Meldung erfassen. Ein
* einzelner Fehler hält die übrigen nicht auf; er wird gemeldet.
*/
public function captureForPressRelease(PressRelease $pressRelease): void
{
$pressRelease->loadMissing('company');
foreach ($pressRelease->images()->whereNotNull('rules_version')->get() as $image) {
$this->capture($image, $pressRelease);
$images = $pressRelease->images()->whereNotNull('rules_version')->publiclyVisible()->get();
foreach ($images as $image) {
try {
$this->capture($image, $pressRelease);
} catch (Throwable $exception) {
report($exception);
}
}
}
/**
* @throws RuntimeException Eine Datei konnte nicht geschrieben werden;
* der Snapshot-Pfad bleibt dann unverändert.
*/
public function capture(PressReleaseImage $image, ?PressRelease $pressRelease = null): void
{
$pressRelease ??= $image->pressRelease;
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null) {
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null || ! $image->isPubliclyDeliverable()) {
return;
}
// Nur, was wirklich öffentlich ist oder war: Eine nie veröffentlichte
// Datei an einer nicht veröffentlichten Meldung bekommt keinen Snapshot.
if (! $image->wasEverPublished() && $pressRelease->status !== PressReleaseStatus::Published) {
return;
}
@ -43,29 +75,16 @@ class PublishedImageSnapshot
$bytes = (string) $sourceDisk->get($coverPath);
$sha = hash('sha256', $bytes);
// Unverändert seit dem letzten Snapshot: nichts zu tun.
if ($image->published_snapshot_sha256 !== null && hash_equals($image->published_snapshot_sha256, $sha) && $image->published_snapshot_path !== null) {
return;
}
$ogPath = $image->pathForVariant('og');
$ogBytes = $ogPath !== null && $sourceDisk->exists($ogPath) ? (string) $sourceDisk->get($ogPath) : null;
$credit = ImageCredit::for($image, $pressRelease->company);
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
$base = sprintf(
'%s/%d/%d-%s',
config('images.published_snapshot_directory'),
$pressRelease->getKey(),
$image->getKey(),
substr($sha, 0, 12),
);
$targetDisk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
$targetDisk->put($base.'.'.$extension, $bytes);
$targetDisk->put($base.'.json', (string) json_encode([
'captured_at' => now()->toIso8601String(),
$metadata = [
'press_release_id' => $pressRelease->getKey(),
'image_id' => $image->getKey(),
'variant' => 'cover',
'variant_sha256' => $sha,
'og_sha256' => $ogBytes !== null ? hash('sha256', $ogBytes) : null,
'original_sha256' => $image->original_sha256,
'credit_line' => $credit->line(),
'credit' => $credit->credit,
@ -74,12 +93,106 @@ class PublishedImageSnapshot
'responsible_line' => $credit->responsibleLine,
'confirmation_key' => $credit->confirmation?->legal_text_key,
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
];
$metadataSha = hash('sha256', (string) json_encode($metadata));
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
$base = sprintf('%s-%s-%s', $this->prefix($image, $pressRelease), substr($sha, 0, 12), substr($metadataSha, 0, 12));
$target = $base.'.'.$extension;
$targetDisk = $this->disk($image);
// Unverändert seit dem letzten Snapshot (Bytes und Angaben): nichts zu tun.
if ($image->published_snapshot_path === $target && $targetDisk->exists($target)) {
if (! $image->wasEverPublished()) {
$image->forceFill(['published_ever_at' => now()])->save();
}
return;
}
$ogTarget = $ogBytes !== null ? $base.'-og.'.(pathinfo((string) $ogPath, PATHINFO_EXTENSION) ?: 'jpg') : null;
$files = [$target => $bytes];
if ($ogTarget !== null) {
$files[$ogTarget] = $ogBytes;
}
$files[$base.'.json'] = (string) json_encode([
'captured_at' => now()->toIso8601String(),
'metadata_sha256' => $metadataSha,
'og_path' => $ogTarget,
] + $metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$written = [];
foreach ($files as $path => $contents) {
if (! $targetDisk->put($path, $contents)) {
// Kein Phantom-Snapshot (N09): Teilstand entfernen, Pfad nicht setzen.
foreach ($written as $partial) {
$targetDisk->delete($partial);
}
throw new RuntimeException(sprintf('Veröffentlichungssnapshot für Bild #%d konnte nicht geschrieben werden (%s).', $image->getKey(), $path));
}
$written[] = $path;
}
$image->forceFill([
'published_snapshot_path' => $base.'.'.$extension,
'published_snapshot_path' => $target,
'published_snapshot_sha256' => $sha,
'published_ever_at' => $image->published_ever_at ?? now(),
])->save();
}
/**
* Alle Snapshot-Dateien eines Bildes (jede Fassung: Cover, OG, JSON),
* auch die aus der Zeit vor der Versionierung.
*
* @return list<string>
*/
public static function versionPaths(PressReleaseImage $image): array
{
$directory = sprintf('%s/%d', config('images.published_snapshot_directory'), $image->press_release_id);
$prefix = $image->getKey().'-';
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
$paths = array_values(array_filter(
$disk->files($directory),
fn (string $path): bool => str_starts_with(basename($path), $prefix),
));
// Der aktuell verknüpfte Pfad gehört immer dazu (auch bei
// abweichender Benennung, z. B. aus Tests oder Altbestand).
foreach (array_filter([$image->published_snapshot_path, self::sidecarPath($image->published_snapshot_path)]) as $current) {
if (! in_array($current, $paths, true) && $disk->exists($current)) {
$paths[] = $current;
}
}
sort($paths);
return $paths;
}
public static function sidecarPath(?string $snapshotPath): ?string
{
if ($snapshotPath === null) {
return null;
}
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $snapshotPath);
return is_string($sidecar) ? $sidecar : null;
}
private function prefix(PressReleaseImage $image, PressRelease $pressRelease): string
{
return sprintf('%s/%d/%d', config('images.published_snapshot_directory'), $pressRelease->getKey(), $image->getKey());
}
private function disk(PressReleaseImage $image): Filesystem
{
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
}
}

View file

@ -888,13 +888,16 @@ class ProofPdfRenderer
/**
* Ermittelt das Titelbild der PM: bevorzugt das als Vorschau markierte,
* sonst das erste. Null, wenn kein echtes Bild vorliegt.
* sonst das erste — nur öffentlich auslieferbare Bilder (Umsetzungsaudit
* 29.09., B-02). Null, wenn kein solches Bild vorliegt.
*/
private function coverImage(PressRelease $pressRelease): ?PressReleaseImage
{
$image = $pressRelease->relationLoaded('images')
? ($pressRelease->images->firstWhere('is_preview', true) ?? $pressRelease->images->first())
: $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
$images = $pressRelease->relationLoaded('images')
? $pressRelease->images->filter(fn (PressReleaseImage $image): bool => $image->isPubliclyDeliverable())
: $pressRelease->images()->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->get();
$image = $images->firstWhere('is_preview', true) ?? $images->first();
return $image instanceof PressReleaseImage ? $image : null;
}