From f1c0e2766b2f4029ce0ee41776b859148081fb60 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 2 Oct 2026 13:09:01 +0000 Subject: [PATCH] =?UTF-8?q?images:=20nur=20freigegebene=20Bilder=20in=20Fe?= =?UTF-8?q?ed=20und=20PDF,=20versionierte=20Ver=C3=B6ffentlichungssnapshot?= =?UTF-8?q?s,=20Best=C3=A4tigung=20an=20das=20gezeigte=20Bild=20gebunden,?= =?UTF-8?q?=20getrennte=20R=C3=BCckhaltegr=C3=BCnde=20(Umsetzungsaudit=202?= =?UTF-8?q?9.09.:=20B-01=20bis=20B-14,=20N09,=20OPT-3=20bis=20OPT-5;=2002.?= =?UTF-8?q?10.:=20O03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../SweepOrphanPressReleaseOriginals.php | 55 ++ .../Api/V1/PressReleaseImageController.php | 10 +- app/Models/PressReleaseImage.php | 64 +- .../Image/ImageProvenanceInspector.php | 16 +- .../Precheck/ImagePrecheckReviewService.php | 33 +- .../Image/Precheck/PrecheckImagePreparer.php | 19 +- .../Images/CustomerImageConfirmation.php | 64 +- .../Images/ImageCorrectionService.php | 72 +- .../Images/PressReleaseImageRemover.php | 160 +++- .../Images/PressReleaseImageUploader.php | 76 +- .../Images/PublishedImageSnapshot.php | 163 +++- .../PressRelease/ProofPdfRenderer.php | 11 +- config/livewire.php | 3 +- ...hheld_at_to_press_release_images_table.php | 28 + ...ck_withheld_at_on_press_release_images.php | 43 + phpstan-baseline.neon | 6 - .../livewire/admin/images/index.blade.php | 2 +- .../admin/press-releases/show.blade.php | 73 +- .../press-release-images-manager-v2.blade.php | 25 +- .../customer/press-releases/show.blade.php | 42 +- .../livewire/web/press-release-feed.blade.php | 2 + tests/Feature/Images/ImageLifecycleTest.php | 10 +- .../Feature/Images/ImagePipelineAuditTest.php | 847 ++++++++++++++++++ 23 files changed, 1675 insertions(+), 149 deletions(-) create mode 100644 app/Console/Commands/SweepOrphanPressReleaseOriginals.php create mode 100644 database/migrations/2026_09_29_180000_add_precheck_withheld_at_to_press_release_images_table.php create mode 100644 database/migrations/2026_09_29_180100_backfill_precheck_withheld_at_on_press_release_images.php create mode 100644 tests/Feature/Images/ImagePipelineAuditTest.php diff --git a/app/Console/Commands/SweepOrphanPressReleaseOriginals.php b/app/Console/Commands/SweepOrphanPressReleaseOriginals.php new file mode 100644 index 00000000..676c139c --- /dev/null +++ b/app/Console/Commands/SweepOrphanPressReleaseOriginals.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index bdc0b98f..276f2fe2 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -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(); diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index a26de52a..ac333461 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -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')); diff --git a/app/Services/Image/ImageProvenanceInspector.php b/app/Services/Image/ImageProvenanceInspector.php index b46d4ef5..9385c37c 100644 --- a/app/Services/Image/ImageProvenanceInspector.php +++ b/app/Services/Image/ImageProvenanceInspector.php @@ -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(); diff --git a/app/Services/Image/Precheck/ImagePrecheckReviewService.php b/app/Services/Image/Precheck/ImagePrecheckReviewService.php index 6226864b..bd7022de 100644 --- a/app/Services/Image/Precheck/ImagePrecheckReviewService.php +++ b/app/Services/Image/Precheck/ImagePrecheckReviewService.php @@ -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); + } } } diff --git a/app/Services/Image/Precheck/PrecheckImagePreparer.php b/app/Services/Image/Precheck/PrecheckImagePreparer.php index c0fd9ca5..66fe6e91 100644 --- a/app/Services/Image/Precheck/PrecheckImagePreparer.php +++ b/app/Services/Image/Precheck/PrecheckImagePreparer.php @@ -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 === '') { diff --git a/app/Services/PressRelease/Images/CustomerImageConfirmation.php b/app/Services/PressRelease/Images/CustomerImageConfirmation.php index f78976f4..86b25f75 100644 --- a/app/Services/PressRelease/Images/CustomerImageConfirmation.php +++ b/app/Services/PressRelease/Images/CustomerImageConfirmation.php @@ -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); + } } } diff --git a/app/Services/PressRelease/Images/ImageCorrectionService.php b/app/Services/PressRelease/Images/ImageCorrectionService.php index a3ab5d8d..73d47499 100644 --- a/app/Services/PressRelease/Images/ImageCorrectionService.php +++ b/app/Services/PressRelease/Images/ImageCorrectionService.php @@ -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); + } + } } diff --git a/app/Services/PressRelease/Images/PressReleaseImageRemover.php b/app/Services/PressRelease/Images/PressReleaseImageRemover.php index 37de782c..27de6e95 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageRemover.php +++ b/app/Services/PressRelease/Images/PressReleaseImageRemover.php @@ -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|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|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')); diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index d01e2e45..0ad62971 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -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(); } diff --git a/app/Services/PressRelease/Images/PublishedImageSnapshot.php b/app/Services/PressRelease/Images/PublishedImageSnapshot.php index 697ef192..84d55f25 100644 --- a/app/Services/PressRelease/Images/PublishedImageSnapshot.php +++ b/app/Services/PressRelease/Images/PublishedImageSnapshot.php @@ -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 + */ + 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')); + } } diff --git a/app/Services/PressRelease/ProofPdfRenderer.php b/app/Services/PressRelease/ProofPdfRenderer.php index ded601b4..9f7e8fd8 100644 --- a/app/Services/PressRelease/ProofPdfRenderer.php +++ b/app/Services/PressRelease/ProofPdfRenderer.php @@ -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; } diff --git a/config/livewire.php b/config/livewire.php index 0d2ba89b..ec362a15 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -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... diff --git a/database/migrations/2026_09_29_180000_add_precheck_withheld_at_to_press_release_images_table.php b/database/migrations/2026_09_29_180000_add_precheck_withheld_at_to_press_release_images_table.php new file mode 100644 index 00000000..b918f441 --- /dev/null +++ b/database/migrations/2026_09_29_180000_add_precheck_withheld_at_to_press_release_images_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_09_29_180100_backfill_precheck_withheld_at_on_press_release_images.php b/database/migrations/2026_09_29_180100_backfill_precheck_withheld_at_on_press_release_images.php new file mode 100644 index 00000000..1af4ea1d --- /dev/null +++ b/database/migrations/2026_09_29_180100_backfill_precheck_withheld_at_on_press_release_images.php @@ -0,0 +1,43 @@ +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')]); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2e6f1cde..05d84c67 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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\\. 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 diff --git a/resources/views/livewire/admin/images/index.blade.php b/resources/views/livewire/admin/images/index.blade.php index 88c142a7..5427dc00 100644 --- a/resources/views/livewire/admin/images/index.blade.php +++ b/resources/views/livewire/admin/images/index.blade.php @@ -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'), diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index 8938726b..1997b680 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -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
{{ __('Bild freigeben') }} {{ __('Bild ablehnen') }} - - {{ __('Beweisoriginal öffnen') }} - + {{-- Zweck per POST, nie in der URL (Audit A-08). --}} +
+ @csrf + + {{ __('Beweisoriginal öffnen') }} +
@@ -428,11 +476,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
{{ __('Korrektur übernehmen') }} - {{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }} - - - {{ __('Beweisoriginal öffnen') }} + {{ $adminTitleImage->isWithheldByCorrection() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }} + {{-- Zweck per POST, nie in der URL (Audit A-08). --}} +
+ @csrf + + {{ __('Beweisoriginal öffnen') }} +
diff --git a/resources/views/livewire/components/press-release-images-manager-v2.blade.php b/resources/views/livewire/components/press-release-images-manager-v2.blade.php index b0c86be5..aeec6be4 100644 --- a/resources/views/livewire/components/press-release-images-manager-v2.blade.php +++ b/resources/views/livewire/components/press-release-images-manager-v2.blade.php @@ -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 */ private function allowedStatuses(): array { - if (auth()->user()?->canAccessAdmin()) { + if ($this->actsAsAdmin()) { return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected]; } diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 9e8d63c9..97de3916 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -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 @endforeach -

{{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird.') }}

+

{{ __('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.') }}

@else

{{ __('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 ============== --}} diff --git a/resources/views/livewire/web/press-release-feed.blade.php b/resources/views/livewire/web/press-release-feed.blade.php index de7e5618..ec7c703c 100644 --- a/resources/views/livewire/web/press-release-feed.blade.php +++ b/resources/views/livewire/web/press-release-feed.blade.php @@ -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), diff --git a/tests/Feature/Images/ImageLifecycleTest.php b/tests/Feature/Images/ImageLifecycleTest.php index 49be57a3..92745d44 100644 --- a/tests/Feature/Images/ImageLifecycleTest.php +++ b/tests/Feature/Images/ImageLifecycleTest.php @@ -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); diff --git a/tests/Feature/Images/ImagePipelineAuditTest.php b/tests/Feature/Images/ImagePipelineAuditTest.php new file mode 100644 index 00000000..7b7157cf --- /dev/null +++ b/tests/Feature/Images/ImagePipelineAuditTest.php @@ -0,0 +1,847 @@ +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 + */ +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(); +});