presseportale/app/Services/PressRelease/Images/PublishedImageSnapshot.php
Kevin Adametz f1c0e2766b 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>
2026-10-02 13:09:01 +00:00

198 lines
7.6 KiB
PHP

<?php
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 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');
$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 || ! $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;
}
$sourceDisk = Storage::disk($image->disk);
$coverPath = $image->pathForVariant('cover') ?? $image->path;
if (! $sourceDisk->exists($coverPath)) {
return;
}
$bytes = (string) $sourceDisk->get($coverPath);
$sha = hash('sha256', $bytes);
$ogPath = $image->pathForVariant('og');
$ogBytes = $ogPath !== null && $sourceDisk->exists($ogPath) ? (string) $sourceDisk->get($ogPath) : null;
$credit = ImageCredit::for($image, $pressRelease->company);
$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,
'ai_declaration' => $image->ai_declaration?->value,
'ai_declaration_source' => $image->ai_declaration_source?->value,
'responsible_line' => $credit->responsibleLine,
'confirmation_key' => $credit->confirmation?->legal_text_key,
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
];
$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' => $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'));
}
}