images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit isPubliclyDeliverable() - B-03: Livewire-Temp-Upload 16 MiB - N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put() geprüft, alle Fassungen ins Archiv - O03: published_ever_at in der Freigabetransaktion (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur - B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre - B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag; Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert - B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme) - B-08, B-09, B-11, B-12: published_ever_at nur bei Status published, Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale, Verarbeitungsfehler als Formularmeldung - K-02-Teil: nach Kundenbestätigung erneut routen - OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08 (Nachweis per POST), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60650baeb8
commit
f1c0e2766b
23 changed files with 1675 additions and 149 deletions
55
app/Console/Commands/SweepOrphanPressReleaseOriginals.php
Normal file
55
app/Console/Commands/SweepOrphanPressReleaseOriginals.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, B-11: Beweisoriginale ohne Bildzeile (harter
|
||||
* Abbruch nach dem Schreiben des Originals, z. B. memory_limit bei
|
||||
* 8000 × 8000 px) nach einer Karenzzeit entfernen.
|
||||
*
|
||||
* Standard ist ein Trockenlauf. Dateien, auf die eine Bildzeile zeigt —
|
||||
* auch weich gelöschte (Beweisarchiv ausstehend) —, bleiben immer liegen.
|
||||
* Nach einem Datenbank-Restore fehlen Zeilen, deren Originale noch Beweis
|
||||
* sind: Solange der Archiv-Sweep ausgesetzt ist (`archive:sweep-orphans
|
||||
* --pause`, setzt auch `archive:reconcile-after-restore`), löscht auch dieser
|
||||
* Befehl nichts.
|
||||
*/
|
||||
class SweepOrphanPressReleaseOriginals extends Command
|
||||
{
|
||||
protected $signature = 'press-release-images:sweep-orphan-originals
|
||||
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
|
||||
{--hours=48 : Nur Dateien, die älter als X Stunden sind}';
|
||||
|
||||
protected $description = 'Entfernt Beweisoriginale ohne zugehörige Bildzeile (B-11)';
|
||||
|
||||
public function handle(PressReleaseImageRemover $remover, EvidenceArchive $archive): int
|
||||
{
|
||||
$execute = (bool) $this->option('execute');
|
||||
|
||||
if ($execute && $archive->isSweepPaused()) {
|
||||
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich) — nichts gelöscht. Fortsetzen mit archive:sweep-orphans --resume.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$result = $remover->sweepOrphanOriginals(
|
||||
$execute,
|
||||
(int) $this->option('hours'),
|
||||
fn (string $file) => $this->line(' verwaist: '.$file),
|
||||
);
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
|
||||
$execute ? 'Bereinigung' : 'Trockenlauf',
|
||||
$result['scanned'],
|
||||
$result['orphans'],
|
||||
$result['removed'],
|
||||
));
|
||||
|
||||
return $execute && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 === '') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@ namespace App\Services\PressRelease\Images;
|
|||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\ArchivePendingJob;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Archive\ArchivePendingQueue;
|
||||
use App\Services\Archive\PressReleaseArchiver;
|
||||
use App\Services\Image\ImageService;
|
||||
use App\Support\FileDeletion;
|
||||
|
|
@ -15,6 +20,7 @@ use App\Support\WebCaches;
|
|||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
|
||||
|
|
@ -22,9 +28,10 @@ use Illuminate\Support\Facades\Storage;
|
|||
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
|
||||
* und vollständig weg, die Zeile wird hart gelöscht — schlägt eine
|
||||
* Dateilöschung fehl, bleibt die Zeile für den nächsten Versuch (5.1 e);
|
||||
* - veröffentlicht: Original, Snapshot und Bestätigung ins Beweisarchiv
|
||||
* (Block F), danach die lokalen Beweisdateien weg und die Pfade an der
|
||||
* Zeile geleert; Derivate weg, Zeile weich gelöscht.
|
||||
* - veröffentlicht: Zeile weich gelöscht und Derivate weg, Auftrag im
|
||||
* Archiv-Nachholer; Original, alle Snapshot-Fassungen und Bestätigung ins
|
||||
* Beweisarchiv (Block F), erst danach die lokalen Beweisdateien weg und
|
||||
* die Pfade an der Zeile geleert.
|
||||
*
|
||||
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
|
||||
* wie bisher behandelt (Derivate weg, Soft Delete).
|
||||
|
|
@ -34,39 +41,103 @@ class PressReleaseImageRemover
|
|||
public function __construct(
|
||||
private readonly ImageService $imageService,
|
||||
private readonly PressReleaseArchiver $archiver,
|
||||
private readonly ArchivePendingQueue $pendingArchive,
|
||||
) {}
|
||||
|
||||
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null): void
|
||||
/**
|
||||
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
|
||||
* Rejected, Admin: alles außer Archived). Meldung und Bild werden unter
|
||||
* Sperre neu geladen, Status-Regel und `published_ever_at` erneut
|
||||
* geprüft (Umsetzungsaudit 29.09., B-05, analog N07): Ein gleichzeitiges
|
||||
* Veröffentlichen kann so kein öffentlich gewesenes Bild in die
|
||||
* Sofortlöschung schicken. Null = keine Regel (System- und Admin-Wege,
|
||||
* die den Status selbst verantworten).
|
||||
*
|
||||
* @param list<PressReleaseStatus>|null $allowedStatuses
|
||||
*
|
||||
* @throws ImageUploadException Status der Meldung passt nicht mehr.
|
||||
*/
|
||||
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?array $allowedStatuses = null): void
|
||||
{
|
||||
if ($image->wasEverPublished() || $image->isLegacyRules()) {
|
||||
// Veröffentlicht: erst der Archiveintrag, dann verschwinden die
|
||||
// Derivate — ein Archivfehler lässt das Bild unangetastet.
|
||||
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
|
||||
$this->archiveTrashed($image, $actor, $event, $reason, $legalRequest);
|
||||
/** @var array{disk: string, path: string, variants: array<string, string>|null, public: bool, job: ArchivePendingJob|null}|null $derivatives */
|
||||
$derivatives = DB::transaction(function () use ($image, $actor, $event, $reason, $legalRequest, $allowedStatuses): ?array {
|
||||
$pressRelease = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($image->press_release_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
|
||||
|
||||
if ($locked === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->delete();
|
||||
WebCaches::forgetPublicPages();
|
||||
if ($allowedStatuses !== null && ($pressRelease === null || ! in_array($pressRelease->status, $allowedStatuses, true))) {
|
||||
throw new ImageUploadException(__('Der Status der Meldung hat sich geändert. Bitte laden Sie die Seite neu.'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
// Die Instanz des Aufrufers auf den gesperrten Stand bringen:
|
||||
// entschieden wird nach dem, was jetzt in der Datenbank steht.
|
||||
$image->setRawAttributes($locked->getAttributes(), true);
|
||||
|
||||
$derivatives = ['disk' => $image->disk, 'path' => $image->path, 'variants' => $image->variants, 'public' => false, 'job' => null];
|
||||
|
||||
if ($image->wasEverPublished() || $image->isLegacyRules()) {
|
||||
// Veröffentlicht: Auftrag „Archivierung steht aus" in derselben
|
||||
// Transaktion wie das Ausblenden (E15, Umsetzungsaudit 29.09.).
|
||||
// Der Eintrag folgt nach dem Commit; scheitert er oder fehlt
|
||||
// der Schlüssel, bleibt der Auftrag offen und die lokalen
|
||||
// Beweisdateien bleiben liegen, bis archive:retry-pending ihn
|
||||
// schreibt.
|
||||
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
|
||||
$derivatives['job'] = $this->pendingArchive->push(
|
||||
(int) $image->press_release_id,
|
||||
$event,
|
||||
$image,
|
||||
$reason,
|
||||
$legalRequest?->getKey(),
|
||||
$actor !== null ? (int) $actor->getKey() : null,
|
||||
);
|
||||
}
|
||||
|
||||
$image->delete();
|
||||
|
||||
return ['public' => true] + $derivatives;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($image): void {
|
||||
foreach ($image->rightsConfirmations()->get() as $confirmation) {
|
||||
$confirmation->purgeForUnpublished();
|
||||
}
|
||||
|
||||
$disk = $this->evidenceDisk($image);
|
||||
|
||||
foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) {
|
||||
foreach (array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)]))) as $path) {
|
||||
FileDeletion::deleteOrThrow($disk, $path, 'Sofortlöschung Bild #'.$image->getKey());
|
||||
}
|
||||
|
||||
$image->forceDelete();
|
||||
|
||||
return $derivatives;
|
||||
});
|
||||
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
if ($derivatives === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($derivatives['job'] !== null) {
|
||||
$this->pendingArchive->completeWith($derivatives['job'], fn () => $this->archiveTrashed($image, $actor, $event, $reason, $legalRequest));
|
||||
}
|
||||
|
||||
$this->imageService->deletePressReleaseImage($derivatives['disk'], $derivatives['path'], $derivatives['variants']);
|
||||
|
||||
if ($derivatives['public']) {
|
||||
try {
|
||||
WebCaches::forgetPublicPages();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,15 +165,8 @@ class PressReleaseImageRemover
|
|||
private function deleteEvidenceFiles(PressReleaseImage $image): void
|
||||
{
|
||||
$disk = $this->evidenceDisk($image);
|
||||
$paths = array_values(array_filter([$image->original_path, $image->published_snapshot_path]));
|
||||
|
||||
if ($image->published_snapshot_path !== null) {
|
||||
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
|
||||
|
||||
if (is_string($sidecar)) {
|
||||
$paths[] = $sidecar;
|
||||
}
|
||||
}
|
||||
// Alle Snapshot-Fassungen (N09) liegen jetzt im Archiv.
|
||||
$paths = array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)])));
|
||||
|
||||
$remaining = FileDeletion::deleteOrLog($disk, $paths, 'Beweisdateien Bild #'.$image->getKey());
|
||||
|
||||
|
|
@ -128,6 +192,50 @@ class PressReleaseImageRemover
|
|||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09., B-11): Der
|
||||
* Uploader schreibt das Original vor der Verarbeitung; ein harter
|
||||
* Abbruch (Fatal Error) hinterlässt eine Datei ohne Zeile. Dateien,
|
||||
* auf die eine Zeile zeigt — auch weich gelöschte —, bleiben liegen.
|
||||
* Die Pfade bleiben hier gekapselt (E.4).
|
||||
*
|
||||
* @param (callable(string): void)|null $report erhält jeden verwaisten Pfad
|
||||
* @return array{scanned: int, orphans: int, removed: int}
|
||||
*/
|
||||
public function sweepOrphanOriginals(bool $execute, int $olderThanHours, ?callable $report = null): array
|
||||
{
|
||||
$disk = Storage::disk((string) config('images.originals_disk', 'local'));
|
||||
$threshold = now()->subHours(max(1, $olderThanHours))->getTimestamp();
|
||||
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
|
||||
|
||||
foreach (array_chunk($disk->allFiles((string) config('images.originals_directory')), 500) as $chunk) {
|
||||
$result['scanned'] += count($chunk);
|
||||
|
||||
$known = PressReleaseImage::withTrashed()
|
||||
->whereIn('original_path', $chunk)
|
||||
->pluck('original_path')
|
||||
->all();
|
||||
|
||||
foreach (array_diff($chunk, $known) as $file) {
|
||||
if ($disk->lastModified($file) > $threshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result['orphans']++;
|
||||
|
||||
if ($report !== null) {
|
||||
$report($file);
|
||||
}
|
||||
|
||||
if ($execute && FileDeletion::delete($disk, $file)) {
|
||||
$result['removed']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function evidenceDisk(PressReleaseImage $image): Filesystem
|
||||
{
|
||||
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,34 +2,66 @@
|
|||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Support\ImageCredit;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Snapshot der veröffentlichten Bildfassung (Bildkennzeichnung E.2,
|
||||
* Decision-Update E2/S1): beim Veröffentlichen wird die ausgelieferte
|
||||
* Cover-Variante zusammen mit dem gerenderten Nachweis und der Kennzeichnung
|
||||
* auf den privaten Snapshot-Pfad kopiert. So lässt sich später belegen, was
|
||||
* mit welchem Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
|
||||
* Cover-Variante zusammen mit der OG-Variante (eingebrannter Nachweis),
|
||||
* dem gerenderten Nachweis und der Kennzeichnung auf den privaten
|
||||
* Snapshot-Pfad kopiert. So lässt sich später belegen, was mit welchem
|
||||
* Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
|
||||
*
|
||||
* Versioniert (Umsetzungsaudit 29.09.2026, N09): Der Pfad enthält den Hash
|
||||
* der Cover-Bytes UND den Hash der öffentlichen Angaben (Nachweis,
|
||||
* Kennzeichnung, Bestätigung, OG-Bytes). Jede Korrektur erzeugt eine neue
|
||||
* Fassung, frühere bleiben liegen und wandern beim Entfernen mit ins
|
||||
* Beweisarchiv (versionPaths()). Erfasst werden nur öffentlich auslieferbare
|
||||
* Bilder; nur sie erhalten `published_ever_at`.
|
||||
*/
|
||||
class PublishedImageSnapshot
|
||||
{
|
||||
/**
|
||||
* Alle öffentlich auslieferbaren v2-Bilder einer Meldung erfassen. Ein
|
||||
* einzelner Fehler hält die übrigen nicht auf; er wird gemeldet.
|
||||
*/
|
||||
public function captureForPressRelease(PressRelease $pressRelease): void
|
||||
{
|
||||
$pressRelease->loadMissing('company');
|
||||
|
||||
foreach ($pressRelease->images()->whereNotNull('rules_version')->get() as $image) {
|
||||
$this->capture($image, $pressRelease);
|
||||
$images = $pressRelease->images()->whereNotNull('rules_version')->publiclyVisible()->get();
|
||||
|
||||
foreach ($images as $image) {
|
||||
try {
|
||||
$this->capture($image, $pressRelease);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException Eine Datei konnte nicht geschrieben werden;
|
||||
* der Snapshot-Pfad bleibt dann unverändert.
|
||||
*/
|
||||
public function capture(PressReleaseImage $image, ?PressRelease $pressRelease = null): void
|
||||
{
|
||||
$pressRelease ??= $image->pressRelease;
|
||||
|
||||
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null) {
|
||||
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null || ! $image->isPubliclyDeliverable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Nur, was wirklich öffentlich ist oder war: Eine nie veröffentlichte
|
||||
// Datei an einer nicht veröffentlichten Meldung bekommt keinen Snapshot.
|
||||
if (! $image->wasEverPublished() && $pressRelease->status !== PressReleaseStatus::Published) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -43,29 +75,16 @@ class PublishedImageSnapshot
|
|||
$bytes = (string) $sourceDisk->get($coverPath);
|
||||
$sha = hash('sha256', $bytes);
|
||||
|
||||
// Unverändert seit dem letzten Snapshot: nichts zu tun.
|
||||
if ($image->published_snapshot_sha256 !== null && hash_equals($image->published_snapshot_sha256, $sha) && $image->published_snapshot_path !== null) {
|
||||
return;
|
||||
}
|
||||
$ogPath = $image->pathForVariant('og');
|
||||
$ogBytes = $ogPath !== null && $sourceDisk->exists($ogPath) ? (string) $sourceDisk->get($ogPath) : null;
|
||||
|
||||
$credit = ImageCredit::for($image, $pressRelease->company);
|
||||
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$base = sprintf(
|
||||
'%s/%d/%d-%s',
|
||||
config('images.published_snapshot_directory'),
|
||||
$pressRelease->getKey(),
|
||||
$image->getKey(),
|
||||
substr($sha, 0, 12),
|
||||
);
|
||||
|
||||
$targetDisk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
$targetDisk->put($base.'.'.$extension, $bytes);
|
||||
$targetDisk->put($base.'.json', (string) json_encode([
|
||||
'captured_at' => now()->toIso8601String(),
|
||||
$metadata = [
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'image_id' => $image->getKey(),
|
||||
'variant' => 'cover',
|
||||
'variant_sha256' => $sha,
|
||||
'og_sha256' => $ogBytes !== null ? hash('sha256', $ogBytes) : null,
|
||||
'original_sha256' => $image->original_sha256,
|
||||
'credit_line' => $credit->line(),
|
||||
'credit' => $credit->credit,
|
||||
|
|
@ -74,12 +93,106 @@ class PublishedImageSnapshot
|
|||
'responsible_line' => $credit->responsibleLine,
|
||||
'confirmation_key' => $credit->confirmation?->legal_text_key,
|
||||
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
];
|
||||
$metadataSha = hash('sha256', (string) json_encode($metadata));
|
||||
|
||||
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$base = sprintf('%s-%s-%s', $this->prefix($image, $pressRelease), substr($sha, 0, 12), substr($metadataSha, 0, 12));
|
||||
$target = $base.'.'.$extension;
|
||||
$targetDisk = $this->disk($image);
|
||||
|
||||
// Unverändert seit dem letzten Snapshot (Bytes und Angaben): nichts zu tun.
|
||||
if ($image->published_snapshot_path === $target && $targetDisk->exists($target)) {
|
||||
if (! $image->wasEverPublished()) {
|
||||
$image->forceFill(['published_ever_at' => now()])->save();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$ogTarget = $ogBytes !== null ? $base.'-og.'.(pathinfo((string) $ogPath, PATHINFO_EXTENSION) ?: 'jpg') : null;
|
||||
$files = [$target => $bytes];
|
||||
|
||||
if ($ogTarget !== null) {
|
||||
$files[$ogTarget] = $ogBytes;
|
||||
}
|
||||
|
||||
$files[$base.'.json'] = (string) json_encode([
|
||||
'captured_at' => now()->toIso8601String(),
|
||||
'metadata_sha256' => $metadataSha,
|
||||
'og_path' => $ogTarget,
|
||||
] + $metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$written = [];
|
||||
|
||||
foreach ($files as $path => $contents) {
|
||||
if (! $targetDisk->put($path, $contents)) {
|
||||
// Kein Phantom-Snapshot (N09): Teilstand entfernen, Pfad nicht setzen.
|
||||
foreach ($written as $partial) {
|
||||
$targetDisk->delete($partial);
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('Veröffentlichungssnapshot für Bild #%d konnte nicht geschrieben werden (%s).', $image->getKey(), $path));
|
||||
}
|
||||
|
||||
$written[] = $path;
|
||||
}
|
||||
|
||||
$image->forceFill([
|
||||
'published_snapshot_path' => $base.'.'.$extension,
|
||||
'published_snapshot_path' => $target,
|
||||
'published_snapshot_sha256' => $sha,
|
||||
'published_ever_at' => $image->published_ever_at ?? now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Snapshot-Dateien eines Bildes (jede Fassung: Cover, OG, JSON),
|
||||
* auch die aus der Zeit vor der Versionierung.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function versionPaths(PressReleaseImage $image): array
|
||||
{
|
||||
$directory = sprintf('%s/%d', config('images.published_snapshot_directory'), $image->press_release_id);
|
||||
$prefix = $image->getKey().'-';
|
||||
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
|
||||
$paths = array_values(array_filter(
|
||||
$disk->files($directory),
|
||||
fn (string $path): bool => str_starts_with(basename($path), $prefix),
|
||||
));
|
||||
|
||||
// Der aktuell verknüpfte Pfad gehört immer dazu (auch bei
|
||||
// abweichender Benennung, z. B. aus Tests oder Altbestand).
|
||||
foreach (array_filter([$image->published_snapshot_path, self::sidecarPath($image->published_snapshot_path)]) as $current) {
|
||||
if (! in_array($current, $paths, true) && $disk->exists($current)) {
|
||||
$paths[] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
sort($paths);
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
public static function sidecarPath(?string $snapshotPath): ?string
|
||||
{
|
||||
if ($snapshotPath === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $snapshotPath);
|
||||
|
||||
return is_string($sidecar) ? $sidecar : null;
|
||||
}
|
||||
|
||||
private function prefix(PressReleaseImage $image, PressRelease $pressRelease): string
|
||||
{
|
||||
return sprintf('%s/%d/%d', config('images.published_snapshot_directory'), $pressRelease->getKey(), $image->getKey());
|
||||
}
|
||||
|
||||
private function disk(PressReleaseImage $image): Filesystem
|
||||
{
|
||||
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, B-07: Vorab-Sichtung (Block K) und
|
||||
* Korrekturweg (E.6) teilten sich `withheld_at`. Die Sichtung bekommt einen
|
||||
* eigenen Grund; `withheld_at` bleibt der Rückhalt aus dem Korrekturweg.
|
||||
* Öffentlich ausgeliefert wird nur, wenn beide leer sind.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_release_images', function (Blueprint $table) {
|
||||
$table->timestamp('precheck_withheld_at')->nullable()->after('withheld_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_release_images', function (Blueprint $table) {
|
||||
$table->dropColumn('precheck_withheld_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, B-07: bestehende Rückhalte der Vorab-Sichtung
|
||||
* übernehmen. Ein Bild mit offener Sichtung und gesetztem `withheld_at`
|
||||
* wandert auf `precheck_withheld_at`; `withheld_at` bleibt nur stehen, wenn
|
||||
* der jüngste Korrektureintrag zum Rückhalt „zurückgehalten" lautet.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$pending = DB::table('press_release_images')
|
||||
->whereNotNull('precheck_review_required_at')
|
||||
->whereNull('precheck_reviewed_at')
|
||||
->whereNotNull('withheld_at')
|
||||
->get(['id', 'withheld_at']);
|
||||
|
||||
foreach ($pending as $image) {
|
||||
$latestCorrection = DB::table('image_declaration_corrections')
|
||||
->where('press_release_image_id', $image->id)
|
||||
->where('field', 'withheld')
|
||||
->orderByDesc('id')
|
||||
->value('new_value');
|
||||
|
||||
DB::table('press_release_images')->where('id', $image->id)->update([
|
||||
'precheck_withheld_at' => $image->withheld_at,
|
||||
'withheld_at' => $latestCorrection === '1' ? $image->withheld_at : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('press_release_images')
|
||||
->whereNotNull('precheck_withheld_at')
|
||||
->whereNull('withheld_at')
|
||||
->update(['withheld_at' => DB::raw('precheck_withheld_at')]);
|
||||
}
|
||||
};
|
||||
|
|
@ -564,12 +564,6 @@ parameters:
|
|||
count: 1
|
||||
path: app/Models/PressRelease.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Database\\Eloquent\\Collection\<int, App\\Models\\PressReleaseImage\>\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 2
|
||||
path: app/Models/PressRelease.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>balance_credits" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Enums\ImagePrecheckCategory;
|
|||
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
|
@ -29,12 +30,28 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$this->id = $id;
|
||||
}
|
||||
|
||||
/*
|
||||
* Berechtigung (Audit K-06): jede Aktion, die Status oder Bild ändert,
|
||||
* verlangt wie Liste und Bearbeiten `press-releases:publish`.
|
||||
*/
|
||||
public function publish(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (QuotaExceededException $e) {
|
||||
Flux::toast(heading: __('Nicht veröffentlicht'), text: $e->getMessage(), variant: 'warning', duration: 8000);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
} catch (ImageRightsConfirmationRequiredException $e) {
|
||||
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
|
@ -58,12 +75,22 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function reject(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$this->validate([
|
||||
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
]);
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-reject')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rejectReason = '';
|
||||
|
||||
|
|
@ -73,8 +100,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function archive(): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
Flux::toast(text: __('Archiviert.'), variant: 'success');
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
}
|
||||
|
|
@ -91,6 +128,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function correctImage(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -145,6 +184,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
private function decidePrecheck(ImagePrecheckReviewService $reviews, bool $approve): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->awaitingPrecheckReview()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -170,6 +211,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public function toggleWithheld(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$this->authorize('press-releases:publish');
|
||||
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
|
|
@ -182,13 +225,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
|
||||
);
|
||||
|
||||
$image->isWithheld()
|
||||
// Nur der Rückhalt aus dem Korrekturweg (B-07): eine offene
|
||||
// Vorab-Sichtung wird in der Sichtungs-Queue entschieden.
|
||||
$image->isWithheldByCorrection()
|
||||
? $corrections->release($image, auth()->user(), $this->correctionReason)
|
||||
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
|
||||
|
||||
$this->reset(['correctionReason']);
|
||||
|
||||
Flux::toast(text: $image->fresh()->isWithheld() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
|
||||
Flux::toast(text: $image->fresh()->isWithheldByCorrection() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -386,9 +431,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button variant="primary" wire:click="approvePrecheck">{{ __('Bild freigeben') }}</flux:button>
|
||||
<flux:button variant="danger" wire:click="rejectPrecheck" wire:confirm="{{ __('Bild wirklich ablehnen und entfernen?') }}">{{ __('Bild ablehnen') }}</flux:button>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Bild-Sichtung nach Vorab-Check']) }}" target="_blank">
|
||||
{{ __('Beweisoriginal öffnen') }}
|
||||
</flux:button>
|
||||
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
|
||||
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
|
||||
@csrf
|
||||
<input type="hidden" name="purpose" value="Bild-Sichtung nach Vorab-Check">
|
||||
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -428,11 +476,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
|
||||
<flux:button variant="filled" wire:click="toggleWithheld">
|
||||
{{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Sichtprüfung im Admin']) }}" target="_blank">
|
||||
{{ __('Beweisoriginal öffnen') }}
|
||||
{{ $adminTitleImage->isWithheldByCorrection() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
|
||||
</flux:button>
|
||||
{{-- Zweck per POST, nie in der URL (Audit A-08). --}}
|
||||
<form method="post" action="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id]) }}" target="_blank">
|
||||
@csrf
|
||||
<input type="hidden" name="purpose" value="Sichtprüfung im Admin">
|
||||
<flux:button type="submit" variant="ghost" size="sm">{{ __('Beweisoriginal öffnen') }}</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,8 @@ new class extends Component {
|
|||
// Vorab-Check (K.3): Ablehnung zeigt den Grund unter dem Dateifeld,
|
||||
// alles andere bleibt als Hinweis oder wird beim Speichern übernommen.
|
||||
try {
|
||||
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease())?->toArray();
|
||||
// Hash aus der Vorprüfung weiterreichen (OPT-4), nicht neu rechnen.
|
||||
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease(), $result->fileSha256)?->toArray();
|
||||
} catch (ImageUploadException $exception) {
|
||||
$this->addError('newImage', $exception->getMessage());
|
||||
$this->resetProvenance();
|
||||
|
|
@ -283,6 +284,13 @@ new class extends Component {
|
|||
} catch (ImageUploadException $exception) {
|
||||
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
|
||||
|
||||
return;
|
||||
} catch (\RuntimeException $exception) {
|
||||
// Verarbeitungsfehler der Bildbibliothek (z. B. animiertes WebP,
|
||||
// B-12): Hinweis am Dateifeld statt Serverfehler.
|
||||
report($exception);
|
||||
$this->addError('newImage', __('Das Bild konnte nicht verarbeitet werden. Bitte speichern Sie es als JPG oder PNG und laden Sie es erneut hoch.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +348,14 @@ new class extends Component {
|
|||
return;
|
||||
}
|
||||
|
||||
$remover->remove($image);
|
||||
try {
|
||||
// Status-Regel unter der Sperre erneut prüfen (B-05).
|
||||
$remover->remove($image, auth()->user(), allowedStatuses: $this->allowedStatuses());
|
||||
} catch (ImageUploadException $exception) {
|
||||
Flux::toast(text: $exception->getMessage(), variant: 'danger');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('title-image-changed');
|
||||
|
||||
|
|
@ -475,12 +490,16 @@ new class extends Component {
|
|||
* Status-Regel des handelnden Nutzers: Kunden ändern das Titelbild nur bei
|
||||
* Entwürfen und abgelehnten PMs, Admins bei allem außer Archiv. Der
|
||||
* Uploader prüft dieselbe Liste unter der Sperre erneut (Audit N07).
|
||||
* Gekoppelt an actsAsAdmin() (B-09): Ein Admin, der selbst Eigentümer
|
||||
* ist, lädt als Kunde hoch (sofort bestätigt) und bekommt deshalb auch
|
||||
* die Kundenregel — sonst wäre ein Bild an einer veröffentlichten
|
||||
* Meldung ohne Admin-Weg sofort öffentlich.
|
||||
*
|
||||
* @return list<PressReleaseStatus>
|
||||
*/
|
||||
private function allowedStatuses(): array
|
||||
{
|
||||
if (auth()->user()?->canAccessAdmin()) {
|
||||
if ($this->actsAsAdmin()) {
|
||||
return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use App\Exceptions\InsufficientCreditsException;
|
|||
use App\Models\BookingConfirmation;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\Billing\CreditPricingService;
|
||||
|
|
@ -121,6 +122,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
#[Locked]
|
||||
public ?string $imageLegalTextKey = null;
|
||||
|
||||
/**
|
||||
* Bild, das die Seite zur Bestätigung anzeigt (Umsetzungsaudit 29.09.,
|
||||
* B-04): Kennung und Datei-Hash werden beim Rendern gesetzt und beim
|
||||
* Bestätigen gegen das aktuelle Titelbild geprüft.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?int $confirmImageId = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $confirmImageSha = null;
|
||||
|
||||
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
|
||||
{
|
||||
$pr = $this->getMyPR();
|
||||
|
|
@ -134,6 +146,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
return;
|
||||
}
|
||||
|
||||
// Bestätigt wird nur das Bild, das der Kunde gesehen hat (B-04).
|
||||
if ($this->confirmImageId !== $image->id || $this->confirmImageSha === null || $image->original_sha256 === null || ! hash_equals($this->confirmImageSha, $image->original_sha256)) {
|
||||
$this->imageRightsConfirmed = false;
|
||||
$this->addError('imageRightsConfirmed', __('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild und bestätigen Sie erneut.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate(
|
||||
['imageRightsConfirmed' => ['accepted']],
|
||||
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
|
||||
|
|
@ -157,6 +177,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
request()->ip(),
|
||||
request()->userAgent(),
|
||||
route('agb', ['edition' => 'de']),
|
||||
$this->confirmImageSha,
|
||||
);
|
||||
} catch (ImageUploadException $exception) {
|
||||
$this->addError('imageRightsConfirmed', $exception->getMessage());
|
||||
|
|
@ -169,6 +190,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Angezeigtes Titelbild für die Bestätigung merken (B-04).
|
||||
*/
|
||||
private function rememberConfirmationImage(?PressReleaseImage $image): ?PressReleaseImage
|
||||
{
|
||||
$this->confirmImageId = $image?->id;
|
||||
$this->confirmImageSha = $image?->original_sha256;
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
|
@ -891,7 +923,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
'categoryName' => $categoryName,
|
||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||
'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
|
||||
'titleImage' => $titleImage = $this->rememberConfirmationImage($pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first()),
|
||||
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('confirmImageRights', $pr),
|
||||
'imageLegalShort' => $imageLegalText
|
||||
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
|
||||
|
|
@ -1671,6 +1703,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
{{ __('Nicht in der Sitemap — für Suchmaschinen ist die Erstfassung auf dem anderen Portal die maßgebliche Adresse (rel=canonical). Leser finden die Meldung weiterhin hier.') }}
|
||||
@elseif ($visibility['in_sitemap_since'])
|
||||
{{ __('Gelistet seit :date (:portals) — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y'), 'portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
|
||||
@elseif (($visibility['sitemap_role'] ?? null) === 'source')
|
||||
{{-- Fassung auch auf dem anderen Portal: Die Rolle kann gewechselt haben, ein Datum wäre geraten (N17). --}}
|
||||
{{ __('In der Sitemap gelistet (:portals). Die Meldung liegt auch auf dem anderen Portal; seit wann diese Fassung die maßgebliche ist, lässt sich nicht genau sagen.', ['portals' => implode(', ', $visibility['sitemap_portals'] ?? [])]) }}
|
||||
@else
|
||||
{{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }}
|
||||
@endif
|
||||
|
|
@ -1711,7 +1746,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird.') }}</p>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Zugeordnet laut Kennung des Abrufers (User-Agent), die sich nicht überprüfen lässt. Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Crawler-Abrufe zählen nicht als Aufrufe.') }}</p>
|
||||
@else
|
||||
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Noch kein Abruf protokolliert.') }}
|
||||
|
|
@ -1851,7 +1886,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
:confirm-label="__('Zur Prüfung freigeben')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
:has-title-image="! $coverIsPlaceholder"
|
||||
:legal-documents="$this->legalDocumentsForSubmit()" />
|
||||
@endif
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
847
tests/Feature/Images/ImagePipelineAuditTest.php
Normal file
847
tests/Feature/Images/ImagePipelineAuditTest.php
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseClassification;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\ArchivePendingJob;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\Archive\PendingArchiveRetry;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\Image\ImageProvenanceInspector;
|
||||
use App\Services\Image\ImageService;
|
||||
use App\Services\Image\Precheck\ImagePrecheckReviewService;
|
||||
use App\Services\Image\Precheck\PrecheckImagePreparer;
|
||||
use App\Services\PressRelease\Images\CustomerImageConfirmation;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\Images\ImageSubmission;
|
||||
use App\Services\PressRelease\Images\ImageUploadActor;
|
||||
use App\Services\PressRelease\Images\ImageUploadException;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageUploader;
|
||||
use App\Services\PressRelease\Images\PublishedImageSnapshot;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\ProofPdfRenderer;
|
||||
use Database\Seeders\LegalDocumentSeeder;
|
||||
use Database\Seeders\LegalTextSeeder;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\Support\FailingDisk;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Umsetzungsaudit 29.09.2026, Gruppe Bild-Pipeline: B-01 bis B-14, N09,
|
||||
* K-02 (Routing nach Bildbestätigung) und OPT-3 bis OPT-5.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$this->seed(LegalDocumentSeeder::class);
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
Storage::fake('local');
|
||||
Storage::fake('public');
|
||||
Storage::fake('archive');
|
||||
Mail::fake();
|
||||
config()->set('images.rights_v2', true);
|
||||
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
FailingDisk::restore('local');
|
||||
FailingDisk::restore('archive');
|
||||
});
|
||||
|
||||
function pipelineAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{owner: User, company: Company, pr: PressRelease}
|
||||
*/
|
||||
function pipelineRelease(string $status = 'published', array $attributes = []): array
|
||||
{
|
||||
$owner = User::factory()->create(['is_active' => true]);
|
||||
$owner->assignRole('customer');
|
||||
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
|
||||
$owner->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$published = $status === 'published';
|
||||
|
||||
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->for($category)->for($company)->create(array_merge([
|
||||
'user_id' => $owner->id,
|
||||
'status' => $status,
|
||||
'language' => 'de',
|
||||
'title' => 'Pipeline-Meldung '.fake()->unique()->numberBetween(1, 99999),
|
||||
'slug' => 'pipeline-'.fake()->unique()->numberBetween(1, 99999),
|
||||
'published_at' => $published ? now()->subHour() : null,
|
||||
'published_ever_at' => $published ? now()->subHour() : null,
|
||||
], $attributes));
|
||||
|
||||
return compact('owner', 'company', 'pr');
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-Bild mit echten Derivaten (für OG-Neubau und Snapshot).
|
||||
*/
|
||||
function pipelineImage(PressRelease $pr, bool $confirmed = true, array $attributes = []): PressReleaseImage
|
||||
{
|
||||
$upload = UploadedFile::fake()->image('foto.jpg', 1600, 900)->size(100);
|
||||
$stored = app(ImageService::class)->storePressReleaseImage($upload, $pr->id, 'local', ogCaption: 'Foto: Alt');
|
||||
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge([
|
||||
'disk' => 'local',
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
'copyright' => 'Alte Agentur',
|
||||
'rights_confirmed_at' => $confirmed ? now() : null,
|
||||
], $attributes));
|
||||
Storage::disk('local')->put($image->original_path, 'original-bytes-'.$image->id);
|
||||
|
||||
if ($confirmed) {
|
||||
ImageRightsConfirmation::query()->create([
|
||||
'subject_type' => $image->getMorphClass(),
|
||||
'subject_id' => $image->getKey(),
|
||||
'press_release_id' => $pr->id,
|
||||
'company_id' => $pr->company_id,
|
||||
'user_id' => $pr->user_id,
|
||||
'channel' => ConfirmationChannel::Form,
|
||||
'legal_text_key' => 'img-confirm-v2',
|
||||
'text_snapshot' => 'Ich bestätige …',
|
||||
'agb_version' => '0.6',
|
||||
'file_sha256' => $image->original_sha256,
|
||||
'declaration_snapshot' => [],
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $image->fresh();
|
||||
}
|
||||
|
||||
function pipelineFixture(string $name): UploadedFile
|
||||
{
|
||||
$copy = tempnam(sys_get_temp_dir(), 'pk-pipeline-');
|
||||
copy(base_path('tests/Fixtures/images/'.$name), $copy);
|
||||
|
||||
return new UploadedFile($copy, $name, mime_content_type($copy), null, true);
|
||||
}
|
||||
|
||||
function pipelineSubmission(): ImageSubmission
|
||||
{
|
||||
return new ImageSubmission(copyright: 'Musterfirma GmbH', aiDeclaration: AiImageDeclaration::Original, rightsConfirmed: true, legalTextKey: 'img-confirm-v2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
function pipelineArchiveFiles(ArchiveEntry $entry): array
|
||||
{
|
||||
$zipBytes = app(EvidenceArchive::class)->open($entry, pipelineAdmin(), 'Test');
|
||||
$path = tempnam(sys_get_temp_dir(), 'pk-zip-');
|
||||
file_put_contents($path, $zipBytes);
|
||||
$zip = new ZipArchive;
|
||||
$zip->open($path);
|
||||
$names = [];
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$names[] = (string) $zip->getNameIndex($i);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@unlink($path);
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- B-01 Feed
|
||||
|
||||
test('the start page feed shows the placeholder for a withheld and for an unconfirmed v2 image', function (array $attributes) {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(array_merge(['copyright' => 'Geheime Agentur'], $attributes));
|
||||
|
||||
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
|
||||
->assertSee($pr->title)
|
||||
->assertDontSee(route('media.press-release-images.show', ['image' => $image->id]), false)
|
||||
->assertDontSee('Geheime Agentur');
|
||||
})->with([
|
||||
'zurückgehalten (Korrektur)' => [['withheld_at' => now()]],
|
||||
'zurückgehalten (Vorab-Sichtung)' => [['precheck_withheld_at' => now()]],
|
||||
'Admin-Upload ohne Kundenbestätigung' => [['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]],
|
||||
]);
|
||||
|
||||
test('the start page feed still shows a released image with its credit', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Freie Agentur']);
|
||||
|
||||
LivewireVolt::test('web.press-release-feed', ['portal' => Portal::Presseecho->value, 'language' => 'de'])
|
||||
->assertSee(route('media.press-release-images.show', ['image' => $image->id]), false)
|
||||
->assertSee('Foto: Freie Agentur');
|
||||
});
|
||||
|
||||
test('the card helpers only return a publicly deliverable image and never lazy load the whole relation', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$withheld = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['withheld_at' => now(), 'is_preview' => true, 'sort_order' => 1]);
|
||||
|
||||
// Unvorgeladen: eigene gefilterte Abfrage, die Relation bleibt ungeladen.
|
||||
$fresh = PressRelease::query()->findOrFail($pr->id);
|
||||
expect($fresh->cardImageUrl())->toBeNull()
|
||||
->and($fresh->thumbImageUrl())->toBeNull()
|
||||
->and($fresh->cardImageCredit())->toBeNull()
|
||||
->and($fresh->relationLoaded('images'))->toBeFalse();
|
||||
|
||||
// Ungefiltert vorgeladen (z. B. Kundenliste): trotzdem kein zurückgehaltenes Bild.
|
||||
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
|
||||
expect($loaded->cardImageUrl())->toBeNull();
|
||||
|
||||
$released = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['copyright' => 'Zweitbild', 'is_preview' => false, 'sort_order' => 2]);
|
||||
$loaded = PressRelease::query()->with('images')->findOrFail($pr->id);
|
||||
|
||||
expect($loaded->cardImageUrl())->toBe($released->url())
|
||||
->and($loaded->cardImageCredit())->toContain('Zweitbild')
|
||||
->and($withheld->isPubliclyDeliverable())->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-02 Nachweis-PDF
|
||||
|
||||
test('the proof pdf never embeds a withheld or unconfirmed title image', function (array $attributes) {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
pipelineImage($pr, confirmed: false, attributes: $attributes);
|
||||
|
||||
$pdf = app(ProofPdfRenderer::class)->render($pr->fresh());
|
||||
|
||||
expect($pdf)->not->toContain('/Subtype /Image');
|
||||
})->with([
|
||||
'zurückgehalten' => [['withheld_at' => now(), 'rights_confirmed_at' => now()]],
|
||||
'unbestätigt' => [['customer_confirmation_requested_at' => now()]],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------- B-03 Upload-Grenze
|
||||
|
||||
test('the livewire temporary upload rule allows the promised 16 mib', function () {
|
||||
expect(config('livewire.temporary_file_upload.rules'))->toBe(['required', 'file', 'max:16384']);
|
||||
});
|
||||
|
||||
test('a 13 mib file passes the livewire upload in the v2 manager', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->set('newImage', UploadedFile::fake()->image('gross.jpg', 1200, 800)->size(13 * 1024))
|
||||
->assertHasNoErrors('newImage');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- N09 Snapshot
|
||||
|
||||
test('a credit correction creates a new snapshot version and keeps the earlier one with its own sidecar', function () {
|
||||
['pr' => $pr] = pipelineRelease('review');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
$first = $image->fresh()->published_snapshot_path;
|
||||
|
||||
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
|
||||
$second = $image->fresh()->published_snapshot_path;
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
$sidecar = fn (string $path): array => json_decode((string) $disk->get(preg_replace('/\.[a-z0-9]+$/i', '.json', $path)), true);
|
||||
|
||||
expect($first)->not->toBeNull()
|
||||
->and($second)->not->toBeNull()
|
||||
->and($second)->not->toBe($first)
|
||||
->and($disk->exists($first))->toBeTrue()
|
||||
->and($disk->exists($second))->toBeTrue()
|
||||
->and($sidecar($first)['credit_line'])->toContain('Alte Agentur')
|
||||
->and($sidecar($second)['credit_line'])->toContain('Neue Agentur')
|
||||
->and($sidecar($second)['og_path'])->not->toBeNull()
|
||||
->and($disk->exists($sidecar($second)['og_path']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('an ai declaration correction also versions the snapshot and an unchanged state is not duplicated', function () {
|
||||
['pr' => $pr] = pipelineRelease('review');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
$first = $image->fresh()->published_snapshot_path;
|
||||
|
||||
app(PublishedImageSnapshot::class)->capture($image->fresh());
|
||||
expect($image->fresh()->published_snapshot_path)->toBe($first);
|
||||
|
||||
app(ImageCorrectionService::class)->correctAiDeclaration($image->fresh(), pipelineAdmin(), AiImageDeclaration::AiEdited, 'Hinweis');
|
||||
|
||||
expect($image->fresh()->published_snapshot_path)->not->toBe($first);
|
||||
});
|
||||
|
||||
test('a failed snapshot write leaves no phantom path and does not break publishing', function () {
|
||||
Exceptions::fake();
|
||||
['pr' => $pr] = pipelineRelease('review');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
FailingDisk::install('local', [
|
||||
'put' => fn ($path, $real) => str_starts_with((string) $path, 'press-release-published/') ? false : $real(),
|
||||
]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
FailingDisk::restore('local');
|
||||
|
||||
$image->refresh();
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
|
||||
->and($image->published_snapshot_path)->toBeNull()
|
||||
->and($image->published_ever_at)->not->toBeNull()
|
||||
->and(Storage::disk('local')->allFiles('press-release-published'))->toBe([]);
|
||||
|
||||
Exceptions::assertReported(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('publishing marks only deliverable images as ever published and snapshots only those', function () {
|
||||
['pr' => $pr] = pipelineRelease('review');
|
||||
$withheld = pipelineImage($pr, attributes: ['withheld_at' => now()]);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published)
|
||||
->and($withheld->fresh()->published_ever_at)->toBeNull()
|
||||
->and($withheld->fresh()->published_snapshot_path)->toBeNull();
|
||||
|
||||
// Freigabe an der veröffentlichten Meldung: jetzt öffentlich, jetzt Snapshot.
|
||||
app(ImageCorrectionService::class)->release($withheld->fresh(), pipelineAdmin(), 'Einwand geprüft');
|
||||
|
||||
expect($withheld->fresh()->published_ever_at)->not->toBeNull()
|
||||
->and($withheld->fresh()->published_snapshot_path)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('removing an image archives every snapshot version', function () {
|
||||
['pr' => $pr] = pipelineRelease('review');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
app(ImageCorrectionService::class)->correctCredit($image->fresh(), pipelineAdmin(), 'Neue Agentur', 'Einwand');
|
||||
|
||||
app(PressReleaseImageRemover::class)->remove($image->fresh());
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->sole();
|
||||
$files = pipelineArchiveFiles($entry);
|
||||
|
||||
expect(collect($files)->filter(fn ($name) => str_starts_with($name, 'published-history/'))->count())->toBeGreaterThanOrEqual(2)
|
||||
->and($files)->toContain('published.json')
|
||||
->and(Storage::disk('local')->allFiles('press-release-published/'.$pr->id))->toBe([]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-04 Bindung an das angezeigte Bild
|
||||
|
||||
test('the customer confirmation is bound to the image shown and refuses a replaced one', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$first = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSet('confirmImageId', $first->id)
|
||||
->set('imageRightsConfirmed', true);
|
||||
|
||||
// Der Admin ersetzt das Bild, während der Kunde die Seite offen hat.
|
||||
$first->forceDelete();
|
||||
$second = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
|
||||
$component->call('confirmImageRights')
|
||||
->assertHasErrors(['imageRightsConfirmed'])
|
||||
->assertSee('Bild wurde inzwischen geändert')
|
||||
->assertSet('confirmImageId', $second->id);
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||
|
||||
$component->set('imageRightsConfirmed', true)
|
||||
->call('confirmImageRights')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($second->rightsConfirmations()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the service refuses a confirmation whose file hash no longer matches', function () {
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
|
||||
expect(fn () => app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null, null, hash('sha256', 'andere-datei')))
|
||||
->toThrow(ImageUploadException::class, 'inzwischen geändert');
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-14 Sperre bei der Bestätigung
|
||||
|
||||
test('a stale image instance of a meanwhile removed image cannot be confirmed', function () {
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
$stale = PressReleaseImage::query()->findOrFail($image->id);
|
||||
|
||||
$image->delete();
|
||||
|
||||
expect(fn () => app(CustomerImageConfirmation::class)->confirm($stale, $owner, 'img-confirm-v2', null, null))
|
||||
->toThrow(ImageUploadException::class);
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- K-02 Routing nach Bestätigung
|
||||
|
||||
test('a green release in review is routed again once the owner confirms the admin upload', function () {
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', ['classification' => PressReleaseClassification::Green->value]);
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
|
||||
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
|
||||
});
|
||||
|
||||
test('a release waiting for manual review or a future date is not routed after the confirmation', function (array $attributes) {
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('review', array_merge(['classification' => PressReleaseClassification::Green->value], $attributes));
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
|
||||
app(CustomerImageConfirmation::class)->confirm($image, $owner, 'img-confirm-v2', null, null);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
})->with([
|
||||
'manuelle Prüfung' => [['manual_review_required_at' => now()]],
|
||||
'Termin in der Zukunft' => [['scheduled_at' => now()->addDay()]],
|
||||
'rot' => [['classification' => PressReleaseClassification::Red->value]],
|
||||
'ungeprüft' => [['classification' => null]],
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------- B-05 Entfernen unter Sperre
|
||||
|
||||
test('the remover re-checks the status rule under the lock', function () {
|
||||
['pr' => $pr] = pipelineRelease('draft');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
// Zwischen Seitenaufbau und Klick wurde die Meldung eingereicht.
|
||||
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
expect(fn () => app(PressReleaseImageRemover::class)->remove($image, allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]))
|
||||
->toThrow(ImageUploadException::class);
|
||||
|
||||
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue()
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a stale never published flag does not hard delete an image that was published meanwhile', function () {
|
||||
['pr' => $pr] = pipelineRelease('draft');
|
||||
$image = pipelineImage($pr);
|
||||
$stale = PressReleaseImage::query()->findOrFail($image->id);
|
||||
|
||||
DB::table('press_release_images')->where('id', $image->id)->update(['published_ever_at' => now()]);
|
||||
|
||||
app(PressReleaseImageRemover::class)->remove($stale);
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the api refuses to remove an image once the status changed under the lock', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$image = pipelineImage($pr);
|
||||
|
||||
$remover = app(PressReleaseImageRemover::class);
|
||||
$this->instance(PressReleaseImageRemover::class, Mockery::mock(PressReleaseImageRemover::class, function (MockInterface $mock) use ($remover, $pr): void {
|
||||
$mock->shouldReceive('remove')->andReturnUsing(function (...$arguments) use ($remover, $pr) {
|
||||
DB::table('press_releases')->where('id', $pr->id)->update(['status' => PressReleaseStatus::Review->value]);
|
||||
|
||||
return $remover->remove(...$arguments);
|
||||
});
|
||||
}));
|
||||
|
||||
Sanctum::actingAs($owner, ['press-release-images:write']);
|
||||
|
||||
$this->deleteJson('/api/v1/press-release-images/'.$image->id)->assertStatus(409);
|
||||
|
||||
expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-06 / A-09 Korrektur
|
||||
|
||||
test('a label correction is archived first and a failing og rebuild only gets reported', function () {
|
||||
/** @var TestCase $this */
|
||||
Exceptions::fake();
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
|
||||
|
||||
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('regenerateOgVariant')->andThrow(new RuntimeException('og kaputt'));
|
||||
}));
|
||||
|
||||
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
|
||||
|
||||
Exceptions::assertReported(fn (RuntimeException $e): bool => $e->getMessage() === 'og kaputt');
|
||||
|
||||
expect($correction->exists)->toBeTrue()
|
||||
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(1)
|
||||
->and(ArchivePendingJob::query()->count())->toBe(0)
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a failed label corrected archive leaves a pending job that the retry turns into the entry', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
|
||||
$correction = app(ImageCorrectionService::class)->correctCredit($image, pipelineAdmin(), 'Neue Agentur', 'Einwand');
|
||||
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
$job = ArchivePendingJob::query()->sole();
|
||||
|
||||
expect($correction->exists)->toBeTrue()
|
||||
->and($image->fresh()->copyright)->toBe('Neue Agentur')
|
||||
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->count())->toBe(0)
|
||||
->and($job->event)->toBe(ArchiveEvent::LabelCorrected->value)
|
||||
->and($job->subject_id)->toBe($correction->id)
|
||||
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
|
||||
|
||||
$result = app(PendingArchiveRetry::class)->retry($job);
|
||||
|
||||
expect($result['failed'])->toBe(0)
|
||||
->and(ArchiveEntry::query()->where('event', ArchiveEvent::LabelCorrected->value)->sole()->summary['correction_id'])->toBe($correction->id)
|
||||
->and(ArchivePendingJob::query()->count())->toBe(0)
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-07 getrennte Rückhaltegründe
|
||||
|
||||
test('approving the precheck review keeps an image withheld by a correction', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$admin = pipelineAdmin();
|
||||
$image = pipelineImage($pr, attributes: [
|
||||
'precheck_review_required_at' => now(),
|
||||
'precheck_withheld_at' => now(),
|
||||
]);
|
||||
|
||||
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
|
||||
app(ImagePrecheckReviewService::class)->approve($image->fresh(), pipelineAdmin(), 'Sichtung ok');
|
||||
|
||||
$image->refresh();
|
||||
|
||||
expect($image->awaitsPrecheckReview())->toBeFalse()
|
||||
->and($image->isWithheld())->toBeTrue()
|
||||
->and($image->isPubliclyDeliverable())->toBeFalse()
|
||||
->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse();
|
||||
|
||||
app(ImageCorrectionService::class)->release($image, $admin, 'Beschwerde erledigt');
|
||||
|
||||
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue();
|
||||
});
|
||||
|
||||
test('releasing a correction does not lift a pending precheck hold', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$admin = pipelineAdmin();
|
||||
$image = pipelineImage($pr, attributes: [
|
||||
'precheck_review_required_at' => now(),
|
||||
'precheck_withheld_at' => now(),
|
||||
]);
|
||||
|
||||
app(ImageCorrectionService::class)->withhold($image->fresh(), $admin, 'Beschwerde');
|
||||
app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Beschwerde erledigt');
|
||||
|
||||
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
|
||||
->and($image->fresh()->isWithheld())->toBeTrue();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- O03 Freigabe ohne Snapshot
|
||||
|
||||
test('an image released on a published release counts as published even if its snapshot fails', function (string $path) {
|
||||
Exceptions::fake();
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$admin = pipelineAdmin();
|
||||
|
||||
$image = match ($path) {
|
||||
'customer confirmation' => pipelineImage($pr, confirmed: false, attributes: ['customer_confirmation_requested_at' => now()]),
|
||||
'precheck approval' => pipelineImage($pr, attributes: ['precheck_review_required_at' => now(), 'precheck_withheld_at' => now()]),
|
||||
'correction release' => pipelineImage($pr, attributes: ['withheld_at' => now()]),
|
||||
};
|
||||
|
||||
expect($image->fresh()->isPubliclyDeliverable())->toBeFalse()
|
||||
->and($image->fresh()->published_ever_at)->toBeNull();
|
||||
|
||||
FailingDisk::install('local', [
|
||||
'put' => fn ($target, $real) => str_starts_with((string) $target, 'press-release-published/') ? false : $real(),
|
||||
]);
|
||||
|
||||
match ($path) {
|
||||
'customer confirmation' => app(PressReleaseImageUploader::class)->confirmExisting($image->fresh(), ImageUploadActor::form($pr->user, null, null), 'img-confirm-v2'),
|
||||
'precheck approval' => app(ImagePrecheckReviewService::class)->approve($image->fresh(), $admin, 'Sichtung ok'),
|
||||
'correction release' => app(ImageCorrectionService::class)->release($image->fresh(), $admin, 'Einwand geprüft'),
|
||||
};
|
||||
|
||||
FailingDisk::restore('local');
|
||||
|
||||
// Öffentlich auslieferbar heißt veröffentlicht (DU E5) — unabhängig vom
|
||||
// Snapshot (Umsetzungsaudit 02.10.2026, O03).
|
||||
expect($image->fresh()->isPubliclyDeliverable())->toBeTrue()
|
||||
->and($image->fresh()->published_snapshot_path)->toBeNull()
|
||||
->and($image->fresh()->published_ever_at)->not->toBeNull();
|
||||
|
||||
// Ein späteres Entfernen nimmt deshalb den Archivweg, nicht die Sofortlöschung.
|
||||
app(PressReleaseImageRemover::class)->remove($image->fresh());
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->find($image->id))->not->toBeNull()
|
||||
->and(
|
||||
ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists()
|
||||
|| ArchivePendingJob::query()->where('event', ArchiveEvent::ImageRemoved->value)->exists()
|
||||
)->toBeTrue();
|
||||
})->with(['customer confirmation', 'precheck approval', 'correction release']);
|
||||
|
||||
// ---------------------------------------------------------------- B-08 / B-09 Uploader
|
||||
|
||||
test('an upload to a rejected release with an old published_at is not marked as ever published', function () {
|
||||
['pr' => $pr] = pipelineRelease('rejected', ['published_at' => now()->subWeek()]);
|
||||
|
||||
$image = app(PressReleaseImageUploader::class)->store(
|
||||
pipelineFixture('plain.png'),
|
||||
$pr,
|
||||
pipelineSubmission(),
|
||||
ImageUploadActor::form($pr->user, null, null),
|
||||
);
|
||||
|
||||
expect($image->published_ever_at)->toBeNull()
|
||||
->and($image->published_snapshot_path)->toBeNull();
|
||||
});
|
||||
|
||||
test('an immediately public upload to a published release gets its snapshot', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
|
||||
$image = app(PressReleaseImageUploader::class)->store(
|
||||
pipelineFixture('plain.png'),
|
||||
$pr,
|
||||
pipelineSubmission(),
|
||||
ImageUploadActor::form($pr->user, null, null),
|
||||
);
|
||||
|
||||
expect($image->fresh()->published_ever_at)->not->toBeNull()
|
||||
->and($image->fresh()->published_snapshot_path)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an admin who owns the release follows the customer status rule in the manager', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$admin = pipelineAdmin();
|
||||
$pr->forceFill(['user_id' => $admin->id])->save();
|
||||
$this->actingAs($admin);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newCopyright', 'Beispiel AG')
|
||||
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newImage']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-10 Bestätigungslink
|
||||
|
||||
test('a later login link does not invalidate the image confirmation link', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]);
|
||||
|
||||
app(CustomerImageConfirmation::class)->request($image);
|
||||
|
||||
$link = MagicLink::query()->where('user_id', $owner->id)->sole();
|
||||
expect($link->purpose)->toBe(MagicLink::PURPOSE_IMAGE_CONFIRMATION)
|
||||
->and($link->expires_at->greaterThan(now()->addHours(71)))->toBeTrue();
|
||||
|
||||
app(MagicLinkGenerator::class)->createLoginLink($owner);
|
||||
|
||||
expect($link->fresh()->consumed_at)->toBeNull();
|
||||
|
||||
// Einlösbar wie ein Login-Link: Token aus der Mail rekonstruieren geht nicht,
|
||||
// deshalb einen zweiten Bestätigungslink erzeugen und den ersten prüfen.
|
||||
$generated = app(MagicLinkGenerator::class)->createImageConfirmationLink($owner, $image->id, ['press_release_id' => $pr->id, 'company_id' => $pr->company_id], null, 60);
|
||||
|
||||
expect($link->fresh()->consumed_at)->not->toBeNull();
|
||||
|
||||
$this->get(route('magic-links.consume', ['token' => $generated['plain_token']]))
|
||||
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($owner);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-11 verwaiste Originale
|
||||
|
||||
test('the originals sweep lists orphans in a dry run and deletes them only with execute', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = pipelineRelease('draft');
|
||||
$image = pipelineImage($pr);
|
||||
$trashed = pipelineImage(pipelineRelease('draft')['pr']);
|
||||
$trashed->delete();
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
|
||||
$fresh = 'press-release-originals/'.$pr->id.'/gerade-geschrieben.jpg';
|
||||
$disk->put($orphan, 'x');
|
||||
$disk->put($fresh, 'y');
|
||||
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
|
||||
touch($disk->path($image->original_path), now()->subDays(3)->getTimestamp());
|
||||
touch($disk->path($trashed->original_path), now()->subDays(3)->getTimestamp());
|
||||
|
||||
$this->artisan('press-release-images:sweep-orphan-originals')
|
||||
->expectsOutputToContain('Trockenlauf: 4 Dateien geprüft, 1 verwaist, 0 entfernt.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($disk->exists($orphan))->toBeTrue();
|
||||
|
||||
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
|
||||
->expectsOutputToContain('Bereinigung: 4 Dateien geprüft, 1 verwaist, 1 entfernt.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($disk->exists($orphan))->toBeFalse()
|
||||
->and($disk->exists($fresh))->toBeTrue()
|
||||
->and($disk->exists($image->original_path))->toBeTrue()
|
||||
->and($disk->exists($trashed->original_path))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the originals sweep deletes nothing while the archive sweep is paused after a restore', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = pipelineRelease('draft');
|
||||
$disk = Storage::disk('local');
|
||||
$orphan = 'press-release-originals/'.$pr->id.'/verwaist.jpg';
|
||||
$disk->put($orphan, 'x');
|
||||
touch($disk->path($orphan), now()->subDays(3)->getTimestamp());
|
||||
|
||||
// Nach einem DB-Restore fehlen Zeilen, deren Originale noch Beweis sind —
|
||||
// dieselbe Pause wie für archive:sweep-orphans (A-03) gilt auch hier.
|
||||
app(EvidenceArchive::class)->pauseSweep();
|
||||
|
||||
$this->artisan('press-release-images:sweep-orphan-originals', ['--execute' => true])
|
||||
->expectsOutputToContain('ausgesetzt')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($disk->exists($orphan))->toBeTrue();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- B-12 Verarbeitungsfehler
|
||||
|
||||
test('a processing error in the image service becomes a form error instead of a server error', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = pipelineRelease('draft');
|
||||
$this->actingAs($owner);
|
||||
|
||||
$this->instance(ImageService::class, Mockery::mock(ImageService::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('storePressReleaseImage')->andThrow(new RuntimeException('Das Bild konnte nicht verarbeitet werden (Titelbild-Variante fehlt).'));
|
||||
$mock->shouldReceive('deletePressReleaseImage')->andReturnNull();
|
||||
}));
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newCopyright', 'Beispiel AG')
|
||||
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||
->set('newRightsConfirmed', true)
|
||||
->call('saveImage')
|
||||
->assertHasErrors(['newImage']);
|
||||
|
||||
expect($pr->images()->count())->toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- OPT-5
|
||||
|
||||
test('the precheck preparer reads only the first frame and returns a flat jpeg', function () {
|
||||
$result = app(PrecheckImagePreparer::class)->prepare(base_path('tests/Fixtures/images/webp-capture.webp'));
|
||||
|
||||
expect(substr($result['bytes'], 0, 2))->toBe("\xFF\xD8")
|
||||
->and($result['width'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- OPT-3
|
||||
|
||||
test('the provenance inspector still finds xmp in png and webp while jpeg is only pinged', function () {
|
||||
$source = new Imagick(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'));
|
||||
$xmp = $source->getImageProfiles('xmp', true)['xmp'];
|
||||
$source->clear();
|
||||
|
||||
$png = new Imagick;
|
||||
$png->newImage(40, 30, 'red');
|
||||
$png->setImageFormat('png');
|
||||
$png->setImageProfile('xmp', $xmp);
|
||||
$path = tempnam(sys_get_temp_dir(), 'pk-xmp-').'.png';
|
||||
$png->writeImage($path);
|
||||
$png->clear();
|
||||
|
||||
$inspector = new ImageProvenanceInspector;
|
||||
|
||||
expect($inspector->inspect($path)->suggestedAiDeclaration()?->value)->toBe('ai_generated')
|
||||
->and($inspector->inspect(base_path('tests/Fixtures/images/webp-capture.webp'))->suggestedAiDeclaration()?->value)->toBe('original')
|
||||
->and($inspector->inspect(base_path('tests/Fixtures/images/xmp-ai-generated.jpg'))->suggestedAiDeclaration()?->value)->toBe('ai_generated');
|
||||
|
||||
@unlink($path);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Remover über Archiv-Aufträge
|
||||
|
||||
test('removing a published image without a working archive leaves an open job and keeps the evidence until the retry', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
|
||||
config()->set('archive.key', null);
|
||||
|
||||
app(PressReleaseImageRemover::class)->remove($image, pipelineAdmin());
|
||||
|
||||
$job = ArchivePendingJob::query()->sole();
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and($job->event)->toBe(ArchiveEvent::ImageRemoved->value)
|
||||
->and($job->subject_id)->toBe($image->id)
|
||||
->and(ArchiveEntry::query()->count())->toBe(0)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
|
||||
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
|
||||
|
||||
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
|
||||
$result = app(PendingArchiveRetry::class)->retry($job);
|
||||
|
||||
expect($result['recorded'])->toBe(1)
|
||||
->and(ArchiveEntry::query()->where('event', ArchiveEvent::ImageRemoved->value)->where('subject_id', $image->id)->exists())->toBeTrue()
|
||||
->and(ArchivePendingJob::query()->count())->toBe(0)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a failing archive write while removing a published image keeps the job open instead of failing the removal', function () {
|
||||
['pr' => $pr] = pipelineRelease();
|
||||
$image = pipelineImage($pr, attributes: ['published_ever_at' => now()->subHour()]);
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
app(PressReleaseImageRemover::class)->remove($image);
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and(ArchivePendingJob::query()->sole()->attempts)->toBe(1)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue