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;
}

View file

@ -65,7 +65,8 @@ return [
'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
// 16 MiB wie Formular und API (Bildkennzeichnung E-B6, Audit 29.09. B-03).
'rules' => ['required', 'file', 'max:16384'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Umsetzungsaudit 29.09.2026, B-07: Vorab-Sichtung (Block K) und
* Korrekturweg (E.6) teilten sich `withheld_at`. Die Sichtung bekommt einen
* eigenen Grund; `withheld_at` bleibt der Rückhalt aus dem Korrekturweg.
* Öffentlich ausgeliefert wird nur, wenn beide leer sind.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->timestamp('precheck_withheld_at')->nullable()->after('withheld_at');
});
}
public function down(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->dropColumn('precheck_withheld_at');
});
}
};

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Umsetzungsaudit 29.09.2026, B-07: bestehende Rückhalte der Vorab-Sichtung
* übernehmen. Ein Bild mit offener Sichtung und gesetztem `withheld_at`
* wandert auf `precheck_withheld_at`; `withheld_at` bleibt nur stehen, wenn
* der jüngste Korrektureintrag zum Rückhalt „zurückgehalten" lautet.
*/
return new class extends Migration
{
public function up(): void
{
$pending = DB::table('press_release_images')
->whereNotNull('precheck_review_required_at')
->whereNull('precheck_reviewed_at')
->whereNotNull('withheld_at')
->get(['id', 'withheld_at']);
foreach ($pending as $image) {
$latestCorrection = DB::table('image_declaration_corrections')
->where('press_release_image_id', $image->id)
->where('field', 'withheld')
->orderByDesc('id')
->value('new_value');
DB::table('press_release_images')->where('id', $image->id)->update([
'precheck_withheld_at' => $image->withheld_at,
'withheld_at' => $latestCorrection === '1' ? $image->withheld_at : null,
]);
}
}
public function down(): void
{
DB::table('press_release_images')
->whereNotNull('precheck_withheld_at')
->whereNull('withheld_at')
->update(['withheld_at' => DB::raw('precheck_withheld_at')]);
}
};

View file

@ -564,12 +564,6 @@ parameters:
count: 1
path: app/Models/PressRelease.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Database\\Eloquent\\Collection\<int, App\\Models\\PressReleaseImage\>\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 2
path: app/Models/PressRelease.php
-
message: '#^Using nullsafe property access "\?\-\>balance_credits" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull

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),

View file

@ -263,13 +263,15 @@ test('the evidence original is only reachable by admins with a purpose and every
$url = route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $image->id], absolute: false);
$this->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen')->assertRedirect();
$this->post('https://pressekonto.test'.$url, ['purpose' => 'Abmahnung prüfen'])->assertRedirect();
$this->actingAs($author)->get('https://pressekonto.test'.$url.'?purpose=x')->assertStatus(403);
$this->actingAs($author)->post('https://pressekonto.test'.$url, ['purpose' => 'x'])->assertStatus(403);
$this->actingAs($admin)->get('https://pressekonto.test'.$url)->assertStatus(422);
// Zweck nur per POST (Umsetzungsaudit 29.09., A-08): GET zeigt das Formular.
$this->actingAs($admin)->get('https://pressekonto.test'.$url.'?purpose=x')->assertSuccessful()->assertSeeText('Zweck des Zugriffs');
$this->actingAs($admin)->post('https://pressekonto.test'.$url)->assertStatus(422);
$response = $this->actingAs($admin)->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen');
$response = $this->actingAs($admin)->post('https://pressekonto.test'.$url, ['purpose' => 'Abmahnung prüfen']);
$response->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private');
expect($response->streamedContent())->toBe('original-bytes-'.$image->id);

View file

@ -0,0 +1,847 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ArchiveEvent;
use App\Enums\ConfirmationChannel;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use App\Services\Auth\MagicLinkGenerator;
use App\Services\Image\ImageProvenanceInspector;
use App\Services\Image\ImageService;
use App\Services\Image\Precheck\ImagePrecheckReviewService;
use App\Services\Image\Precheck\PrecheckImagePreparer;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\ProofPdfRenderer;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
use Livewire\Volt\Volt as LivewireVolt;
use Mockery\MockInterface;
use Tests\Support\FailingDisk;
use Tests\TestCase;
/**
* Umsetzungsaudit 29.09.2026, Gruppe Bild-Pipeline: B-01 bis B-14, N09,
* K-02 (Routing nach Bildbestätigung) und OPT-3 bis OPT-5.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
Storage::fake('local');
Storage::fake('public');
Storage::fake('archive');
Mail::fake();
config()->set('images.rights_v2', true);
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
});
afterEach(function (): void {
FailingDisk::restore('local');
FailingDisk::restore('archive');
});
function pipelineAdmin(): User
{
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
return $admin;
}
/**
* @return array{owner: User, company: Company, pr: PressRelease}
*/
function pipelineRelease(string $status = 'published', array $attributes = []): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$owner->companies()->attach($company->id, ['role' => 'owner']);
$published = $status === 'published';
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->for($category)->for($company)->create(array_merge([
'user_id' => $owner->id,
'status' => $status,
'language' => 'de',
'title' => 'Pipeline-Meldung '.fake()->unique()->numberBetween(1, 99999),
'slug' => 'pipeline-'.fake()->unique()->numberBetween(1, 99999),
'published_at' => $published ? now()->subHour() : null,
'published_ever_at' => $published ? now()->subHour() : null,
], $attributes));
return compact('owner', 'company', 'pr');
}
/**
* v2-Bild mit echten Derivaten (für OG-Neubau und Snapshot).
*/
function pipelineImage(PressRelease $pr, bool $confirmed = true, array $attributes = []): PressReleaseImage
{
$upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100);
$stored = app(ImageService::class)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge([
'disk' => 'local',
'path' => $stored['path'],
'variants' => $stored['variants'],
'copyright' => 'Alte Agentur',
'rights_confirmed_at' => $confirmed ? now() : null,
], $attributes));
Storage::disk('local')->put($image->original_path, 'original-bytes-'.$image->id);
if ($confirmed) {
ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $pr->id,
'company_id' => $pr->company_id,
'user_id' => $pr->user_id,
'channel' => ConfirmationChannel::Form,
'legal_text_key' => 'img-confirm-v2',
'text_snapshot' => 'Ich bestätige …',
'agb_version' => '0.6',
'file_sha256' => $image->original_sha256,
'declaration_snapshot' => [],
'confirmed_at' => now(),
'created_at' => now(),
]);
}
return $image->fresh();
}
function pipelineFixture(string $name): UploadedFile
{
$copy = tempnam(sys_get_temp_dir(), 'pk-pipeline-');
copy(base_path('tests/Fixtures/images/'.$name), $copy);
return new UploadedFile($copy, $name, mime_content_type($copy), null, true);
}
function pipelineSubmission(): ImageSubmission
{
return new ImageSubmission(copyright: 'Musterfirma GmbH', aiDeclaration: AiImageDeclaration::Original, rightsConfirmed: true, legalTextKey: 'img-confirm-v2');
}
/**
* @return list<string>
*/
function pipelineArchiveFiles(ArchiveEntry $entry): array
{
$zipBytes = app(EvidenceArchive::class)->open($entry, pipelineAdmin(), 'Test');
$path = tempnam(sys_get_temp_dir(), 'pk-zip-');
file_put_contents($path, $zipBytes);
$zip = new ZipArchive;
$zip->open($path);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$names[] = (string) $zip->getNameIndex($i);
}
$zip->close();
@unlink($path);
return $names;
}
// ---------------------------------------------------------------- B-01 Feed
test('the start page feed shows the placeholder for a withheld and for an unconfirmed v2 image', function (array $attributes) {
['pr' => $pr] = pipelineRelease();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge(['copyright' => 'Geheime Agentur'], $attributes));
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
->assertSee($pr->title)
->assertDontSee(route('media.press-release-images.show', ['image' => $image->id]), false)
->assertDontSee('Geheime Agentur');
})->with([
'zurückgehalten (Korrektur)' => [['withheld_at' => now()]],
'zurückgehalten (Vorab-Sichtung)' => [['precheck_withheld_at' => now()]],
'Admin-Upload ohne Kundenbestätigung' => [['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]],
]);
test('the start page feed still shows a released image with its credit', function () {
['pr' => $pr] = pipelineRelease();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Freie Agentur']);
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
->assertSee(route('media.press-release-images.show', ['image' => $image->id]), false)
->assertSee('Foto: Freie Agentur');
});
test('the card helpers only return a publicly deliverable image and never lazy load the whole relation', function () {
['pr' => $pr] = pipelineRelease();
$withheld = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['withheld_at' => now(), 'is_preview' => true, 'sort_order' => 1]);
// Unvorgeladen: eigene gefilterte Abfrage, die Relation bleibt ungeladen.
$fresh = PressRelease::query()->findOrFail($pr->id);
expect($fresh->cardImageUrl())->toBeNull()
->and($fresh->thumbImageUrl())->toBeNull()
->and($fresh->cardImageCredit())->toBeNull()
->and($fresh->relationLoaded('images'))->toBeFalse();
// Ungefiltert vorgeladen (z. B. Kundenliste): trotzdem kein zurückgehaltenes Bild.
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
expect($loaded->cardImageUrl())->toBeNull();
$released = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Zweitbild', 'is_preview' => false, 'sort_order' => 2]);
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
expect($loaded->cardImageUrl())->toBe($released->url())
->and($loaded->cardImageCredit())->toContain('Zweitbild')
->and($withheld->isPubliclyDeliverable())->toBeFalse();
});
// ---------------------------------------------------------------- B-02 Nachweis-PDF
test('the proof pdf never embeds a withheld or unconfirmed title image', function (array $attributes) {
['pr' => $pr] = pipelineRelease();
pipelineImage($pr, confirmed: false, attributes: $attributes);
$pdf = app(ProofPdfRenderer::class)->render($pr->fresh());
expect($pdf)->not->toContain('/Subtype /Image');
})->with([
'zurückgehalten' => [['withheld_at' => now(), 'rights_confirmed_at' => now()]],
'unbestätigt' => [['customer_confirmation_requested_at' => now()]],
]);
// ---------------------------------------------------------------- B-03 Upload-Grenze
test('the livewire temporary upload rule allows the promised 16 mib', function () {
expect(config('livewire.temporary_file_upload.rules'))->toBe(['required', 'file', 'max:16384']);
});
test('a 13 mib file passes the livewire upload in the v2 manager', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$this->actingAs($owner);
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('gross.jpg', 1200, 800)->size(13 * 1024))
->assertHasNoErrors('newImage');
});
// ---------------------------------------------------------------- N09 Snapshot
test('a credit correction creates a new snapshot version and keeps the earlier one with its own sidecar', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
$first = $image->fresh()->published_snapshot_path;
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
$second = $image->fresh()->published_snapshot_path;
$disk = Storage::disk('local');
$sidecar = fn (string $path): array => json_decode((string) $disk->get(preg_replace('/\.[a-z0-9]+$/i', '.json', $path)), true);
expect($first)->not->toBeNull()
->and($second)->not->toBeNull()
->and($second)->not->toBe($first)
->and($disk->exists($first))->toBeTrue()
->and($disk->exists($second))->toBeTrue()
->and($sidecar($first)['credit_line'])->toContain('Alte Agentur')
->and($sidecar($second)['credit_line'])->toContain('Neue Agentur')
->and($sidecar($second)['og_path'])->not->toBeNull()
->and($disk->exists($sidecar($second)['og_path']))->toBeTrue();
});
test('an ai declaration correction also versions the snapshot and an unchanged state is not duplicated', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
$first = $image->fresh()->published_snapshot_path;
app(PublishedImageSnapshot::class)->capture($image->fresh());
expect($image->fresh()->published_snapshot_path)->toBe($first);
app(ImageCorrectionService::class)->correctAiDeclaration($image->fresh(), pipelineAdmin(), AiImageDeclaration::AiEdited, 'Hinweis');
expect($image->fresh()->published_snapshot_path)->not->toBe($first);
});
test('a failed snapshot write leaves no phantom path and does not break publishing', function () {
Exceptions::fake();
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
FailingDisk::install('local', [
'put' => fn ($path, $real) => str_starts_with((string) $path, 'press-release-published/') ? false : $real(),
]);
app(PressReleaseService::class)->publish($pr);
FailingDisk::restore('local');
$image->refresh();
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($image->published_snapshot_path)->toBeNull()
->and($image->published_ever_at)->not->toBeNull()
->and(Storage::disk('local')->allFiles('press-release-published'))->toBe([]);
Exceptions::assertReported(RuntimeException::class);
});
test('publishing marks only deliverable images as ever published and snapshots only those', function () {
['pr' => $pr] = pipelineRelease('review');
$withheld = pipelineImage($pr, attributes: ['withheld_at' => now()]);
app(PressReleaseService::class)->publish($pr);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
->and($withheld->fresh()->published_ever_at)->toBeNull()
->and($withheld->fresh()->published_snapshot_path)->toBeNull();
// Freigabe an der veröffentlichten Meldung: jetzt öffentlich, jetzt Snapshot.
app(ImageCorrectionService::class)->release($withheld->fresh(), pipelineAdmin(), 'Einwand geprüft');
expect($withheld->fresh()->published_ever_at)->not->toBeNull()
->and($withheld->fresh()->published_snapshot_path)->not->toBeNull();
});
test('removing an image archives every snapshot version', function () {
['pr' => $pr] = pipelineRelease('review');
$image = pipelineImage($pr);
app(PressReleaseService::class)->publish($pr);
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
app(PressReleaseImageRemover::class)->remove($image->fresh());
$entry = ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->sole();
$files = pipelineArchiveFiles($entry);
expect(collect($files)->filter(fn ($name) => str_starts_with($name, 'published-history/'))->count())->toBeGreaterThanOrEqual(2)
->and($files)->toContain('published.json')
->and(Storage::disk('local')->allFiles('press-release-published/'.$pr->id))->toBe([]);
});
// ---------------------------------------------------------------- B-04 Bindung an das angezeigte Bild
test('the customer confirmation is bound to the image shown and refuses a replaced one', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$first = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$this->actingAs($owner);
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSet('confirmImageId', $first->id)
->set('imageRightsConfirmed', true);
// Der Admin ersetzt das Bild, während der Kunde die Seite offen hat.
$first->forceDelete();
$second = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$component->call('confirmImageRights')
->assertHasErrors(['imageRightsConfirmed'])
->assertSee('Bild wurde inzwischen geändert')
->assertSet('confirmImageId', $second->id);
expect(ImageRightsConfirmation::query()->count())->toBe(0);
$component->set('imageRightsConfirmed', true)
->call('confirmImageRights')
->assertHasNoErrors();
expect($second->rightsConfirmations()->count())->toBe(1);
});
test('the service refuses a confirmation whose file hash no longer matches', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
expect(fn () => app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null, null, hash('sha256', 'andere-datei')))
->toThrow(ImageUploadException::class, 'inzwischen geändert');
expect(ImageRightsConfirmation::query()->count())->toBe(0);
});
// ---------------------------------------------------------------- B-14 Sperre bei der Bestätigung
test('a stale image instance of a meanwhile removed image cannot be confirmed', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
$stale = PressReleaseImage::query()->findOrFail($image->id);
$image->delete();
expect(fn () => app(CustomerImageConfirmation::class)->confirm($stale, $owner, 'img-confirm-v2', null, null))
->toThrow(ImageUploadException::class);
expect(ImageRightsConfirmation::query()->count())->toBe(0);
});
// ---------------------------------------------------------------- K-02 Routing nach Bestätigung
test('a green release in review is routed again once the owner confirms the admin upload', function () {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', ['classification' => PressReleaseClassification::Green->value]);
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
});
test('a release waiting for manual review or a future date is not routed after the confirmation', function (array $attributes) {
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', array_merge(['classification' => PressReleaseClassification::Green->value], $attributes));
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
})->with([
'manuelle Prüfung' => [['manual_review_required_at' => now()]],
'Termin in der Zukunft' => [['scheduled_at' => now()->addDay()]],
'rot' => [['classification' => PressReleaseClassification::Red->value]],
'ungeprüft' => [['classification' => null]],
]);
// ---------------------------------------------------------------- B-05 Entfernen unter Sperre
test('the remover re-checks the status rule under the lock', function () {
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
// Zwischen Seitenaufbau und Klick wurde die Meldung eingereicht.
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
expect(fn () => app(PressReleaseImageRemover::class)->remove($image, allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]))
->toThrow(ImageUploadException::class);
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
});
test('a stale never published flag does not hard delete an image that was published meanwhile', function () {
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$stale = PressReleaseImage::query()->findOrFail($image->id);
DB::table('press_release_images')->where('id', $image->id)->update(['published_ever_at' => now()]);
app(PressReleaseImageRemover::class)->remove($stale);
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue();
});
test('the api refuses to remove an image once the status changed under the lock', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$remover = app(PressReleaseImageRemover::class);
$this->instance(PressReleaseImageRemover::class, Mockery::mock(PressReleaseImageRemover::class, function (MockInterface $mock) use ($remover, $pr): void {
$mock->shouldReceive('remove')->andReturnUsing(function (...$arguments) use ($remover, $pr) {
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
return $remover->remove(...$arguments);
});
}));
Sanctum::actingAs($owner, ['press-release-images:write']);
$this->deleteJson('/api/v1/press-release-images/'.$image->id)->assertStatus(409);
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue();
});
// ---------------------------------------------------------------- B-06 / A-09 Korrektur
test('a label correction is archived first and a failing og rebuild only gets reported', function () {
/** @var TestCase $this */
Exceptions::fake();
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
$mock->shouldReceive('regenerateOgVariant')->andThrow(new RuntimeException('og kaputt'));
}));
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
Exceptions::assertReported(fn (RuntimeException $e): bool => $e->getMessage() === 'og kaputt');
expect($correction->exists)->toBeTrue()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(1)
->and(ArchivePendingJob::query()->count())->toBe(0)
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('a failed label corrected archive leaves a pending job that the retry turns into the entry', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
FailingDisk::install('archive', ['put' => fn () => false]);
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
FailingDisk::restore('archive');
$job = ArchivePendingJob::query()->sole();
expect($correction->exists)->toBeTrue()
->and($image->fresh()->copyright)->toBe('Neue Agentur')
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(0)
->and($job->event)->toBe(ArchiveEvent::LabelCorrected->value)
->and($job->subject_id)->toBe($correction->id)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$result = app(PendingArchiveRetry::class)->retry($job);
expect($result['failed'])->toBe(0)
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->sole()->summary['correction_id'])->toBe($correction->id)
->and(ArchivePendingJob::query()->count())->toBe(0)
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
// ---------------------------------------------------------------- B-07 getrennte Rückhaltegründe
test('approving the precheck review keeps an image withheld by a correction', function () {
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = pipelineImage($pr, attributes: [
'precheck_review_required_at' => now(),
'precheck_withheld_at' => now(),
]);
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
app(ImagePrecheckReviewService::class)->approve($image->fresh(), pipelineAdmin(), 'Sichtung ok');
$image->refresh();
expect($image->awaitsPrecheckReview())->toBeFalse()
->and($image->isWithheld())->toBeTrue()
->and($image->isPubliclyDeliverable())->toBeFalse()
->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse();
app(ImageCorrectionService::class)->release($image, $admin, 'Beschwerde erledigt');
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue();
});
test('releasing a correction does not lift a pending precheck hold', function () {
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = pipelineImage($pr, attributes: [
'precheck_review_required_at' => now(),
'precheck_withheld_at' => now(),
]);
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Beschwerde erledigt');
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
->and($image->fresh()->isWithheld())->toBeTrue();
});
// ---------------------------------------------------------------- O03 Freigabe ohne Snapshot
test('an image released on a published release counts as published even if its snapshot fails', function (string $path) {
Exceptions::fake();
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$image = match ($path) {
'customer confirmation' => pipelineImage($pr, confirmed: false, attributes: ['customer_confirmation_requested_at' => now()]),
'precheck approval' => pipelineImage($pr, attributes: ['precheck_review_required_at' => now(), 'precheck_withheld_at' => now()]),
'correction release' => pipelineImage($pr, attributes: ['withheld_at' => now()]),
};
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
->and($image->fresh()->published_ever_at)->toBeNull();
FailingDisk::install('local', [
'put' => fn ($target, $real) => str_starts_with((string) $target, 'press-release-published/') ? false : $real(),
]);
match ($path) {
'customer confirmation' => app(PressReleaseImageUploader::class)->confirmExisting($image->fresh(), ImageUploadActor::form($pr->user, null, null), 'img-confirm-v2'),
'precheck approval' => app(ImagePrecheckReviewService::class)->approve($image->fresh(), $admin, 'Sichtung ok'),
'correction release' => app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Einwand geprüft'),
};
FailingDisk::restore('local');
// Öffentlich auslieferbar heißt veröffentlicht (DU E5) — unabhängig vom
// Snapshot (Umsetzungsaudit 02.10.2026, O03).
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue()
->and($image->fresh()->published_snapshot_path)->toBeNull()
->and($image->fresh()->published_ever_at)->not->toBeNull();
// Ein späteres Entfernen nimmt deshalb den Archivweg, nicht die Sofortlöschung.
app(PressReleaseImageRemover::class)->remove($image->fresh());
expect(PressReleaseImage::withTrashed()->find($image->id))->not->toBeNull()
->and(
ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists()
|| ArchivePendingJob::query()->where('event', ArchiveEvent::ImageRemoved->value)->exists()
)->toBeTrue();
})->with(['customer confirmation', 'precheck approval', 'correction release']);
// ---------------------------------------------------------------- B-08 / B-09 Uploader
test('an upload to a rejected release with an old published_at is not marked as ever published', function () {
['pr' => $pr] = pipelineRelease('rejected', ['published_at' => now()->subWeek()]);
$image = app(PressReleaseImageUploader::class)->store(
pipelineFixture('plain.png'),
$pr,
pipelineSubmission(),
ImageUploadActor::form($pr->user, null, null),
);
expect($image->published_ever_at)->toBeNull()
->and($image->published_snapshot_path)->toBeNull();
});
test('an immediately public upload to a published release gets its snapshot', function () {
['pr' => $pr] = pipelineRelease();
$image = app(PressReleaseImageUploader::class)->store(
pipelineFixture('plain.png'),
$pr,
pipelineSubmission(),
ImageUploadActor::form($pr->user, null, null),
);
expect($image->fresh()->published_ever_at)->not->toBeNull()
->and($image->fresh()->published_snapshot_path)->not->toBeNull();
});
test('an admin who owns the release follows the customer status rule in the manager', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease();
$admin = pipelineAdmin();
$pr->forceFill(['user_id' => $admin->id])->save();
$this->actingAs($admin);
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasErrors(['newImage']);
expect($pr->images()->count())->toBe(0);
});
// ---------------------------------------------------------------- B-10 Bestätigungslink
test('a later login link does not invalidate the image confirmation link', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
app(CustomerImageConfirmation::class)->request($image);
$link = MagicLink::query()->where('user_id', $owner->id)->sole();
expect($link->purpose)->toBe(MagicLink::PURPOSE_IMAGE_CONFIRMATION)
->and($link->expires_at->greaterThan(now()->addHours(71)))->toBeTrue();
app(MagicLinkGenerator::class)->createLoginLink($owner);
expect($link->fresh()->consumed_at)->toBeNull();
// Einlösbar wie ein Login-Link: Token aus der Mail rekonstruieren geht nicht,
// deshalb einen zweiten Bestätigungslink erzeugen und den ersten prüfen.
$generated = app(MagicLinkGenerator::class)->createImageConfirmationLink($owner, $image->id, ['press_release_id' => $pr->id, 'company_id' => $pr->company_id], null, 60);
expect($link->fresh()->consumed_at)->not->toBeNull();
$this->get(route('magic-links.consume', ['token' => $generated['plain_token']]))
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
$this->assertAuthenticatedAs($owner);
});
// ---------------------------------------------------------------- B-11 verwaiste Originale
test('the originals sweep lists orphans in a dry run and deletes them only with execute', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease('draft');
$image = pipelineImage($pr);
$trashed = pipelineImage(pipelineRelease('draft')['pr']);
$trashed->delete();
$disk = Storage::disk('local');
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
$fresh = 'press-release-originals/'.$pr->id.'/gerade-geschrieben.jpg';
$disk->put($orphan, 'x');
$disk->put($fresh, 'y');
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
touch($disk->path($image->original_path), now()->subDays(3)->getTimestamp());
touch($disk->path($trashed->original_path), now()->subDays(3)->getTimestamp());
$this->artisan('press-release-images:sweep-orphan-originals')
->expectsOutputToContain('Trockenlauf: 4 Dateien geprüft, 1 verwaist, 0 entfernt.')
->assertSuccessful();
expect($disk->exists($orphan))->toBeTrue();
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
->expectsOutputToContain('Bereinigung: 4 Dateien geprüft, 1 verwaist, 1 entfernt.')
->assertSuccessful();
expect($disk->exists($orphan))->toBeFalse()
->and($disk->exists($fresh))->toBeTrue()
->and($disk->exists($image->original_path))->toBeTrue()
->and($disk->exists($trashed->original_path))->toBeTrue();
});
test('the originals sweep deletes nothing while the archive sweep is paused after a restore', function () {
/** @var TestCase $this */
['pr' => $pr] = pipelineRelease('draft');
$disk = Storage::disk('local');
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
$disk->put($orphan, 'x');
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
// Nach einem DB-Restore fehlen Zeilen, deren Originale noch Beweis sind —
// dieselbe Pause wie für archive:sweep-orphans (A-03) gilt auch hier.
app(EvidenceArchive::class)->pauseSweep();
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
->expectsOutputToContain('ausgesetzt')
->assertSuccessful();
expect($disk->exists($orphan))->toBeTrue();
});
// ---------------------------------------------------------------- B-12 Verarbeitungsfehler
test('a processing error in the image service becomes a form error instead of a server error', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
$this->actingAs($owner);
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
$mock->shouldReceive('storePressReleaseImage')->andThrow(new RuntimeException('Das Bild konnte nicht verarbeitet werden (Titelbild-Variante fehlt).'));
$mock->shouldReceive('deletePressReleaseImage')->andReturnNull();
}));
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
->call('openUploadForm')
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
->set('newCopyright', 'Beispiel AG')
->set('newAiDeclaration', AiImageDeclaration::Original->value)
->set('newRightsConfirmed', true)
->call('saveImage')
->assertHasErrors(['newImage']);
expect($pr->images()->count())->toBe(0);
});
// ---------------------------------------------------------------- OPT-5
test('the precheck preparer reads only the first frame and returns a flat jpeg', function () {
$result = app(PrecheckImagePreparer::class)->prepare(base_path('tests/Fixtures/images/webp-capture.webp'));
expect(substr($result['bytes'], 0, 2))->toBe("\xFF\xD8")
->and($result['width'])->toBeGreaterThan(0);
});
// ---------------------------------------------------------------- OPT-3
test('the provenance inspector still finds xmp in png and webp while jpeg is only pinged', function () {
$source = new Imagick(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'));
$xmp = $source->getImageProfiles('xmp', true)['xmp'];
$source->clear();
$png = new Imagick;
$png->newImage(40, 30, 'red');
$png->setImageFormat('png');
$png->setImageProfile('xmp', $xmp);
$path = tempnam(sys_get_temp_dir(), 'pk-xmp-').'.png';
$png->writeImage($path);
$png->clear();
$inspector = new ImageProvenanceInspector;
expect($inspector->inspect($path)->suggestedAiDeclaration()?->value)->toBe('ai_generated')
->and($inspector->inspect(base_path('tests/Fixtures/images/webp-capture.webp'))->suggestedAiDeclaration()?->value)->toBe('original')
->and($inspector->inspect(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'))->suggestedAiDeclaration()?->value)->toBe('ai_generated');
@unlink($path);
});
// ---------------------------------------------------------------- Remover über Archiv-Aufträge
test('removing a published image without a working archive leaves an open job and keeps the evidence until the retry', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
config()->set('archive.key', null);
app(PressReleaseImageRemover::class)->remove($image, pipelineAdmin());
$job = ArchivePendingJob::query()->sole();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and($job->event)->toBe(ArchiveEvent::ImageRemoved->value)
->and($job->subject_id)->toBe($image->id)
->and(ArchiveEntry::query()->count())->toBe(0)
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
$result = app(PendingArchiveRetry::class)->retry($job);
expect($result['recorded'])->toBe(1)
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue()
->and(ArchivePendingJob::query()->count())->toBe(0)
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('a failing archive write while removing a published image keeps the job open instead of failing the removal', function () {
['pr' => $pr] = pipelineRelease();
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseImageRemover::class)->remove($image);
FailingDisk::restore('archive');
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(ArchivePendingJob::query()->sole()->attempts)->toBe(1)
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
});