- 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>
198 lines
7.6 KiB
PHP
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'));
|
|
}
|
|
}
|