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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue