presseportale/app/Services/PressRelease/Images/PressReleaseImageUploader.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

528 lines
23 KiB
PHP

<?php
namespace App\Services\PressRelease\Images;
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\CreditSource;
use App\Enums\ImagePrecheckOutcome;
use App\Enums\PressReleaseStatus;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalText;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageProvenanceInspector;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\Image\Precheck\ImagePrecheckEvaluation;
use App\Services\Image\Precheck\ImagePrecheckResult;
use App\Services\Image\Precheck\ImagePrecheckService;
use App\Services\Image\ProvenanceResult;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* Gemeinsamer Upload-Ablauf für Titelbilder (Bildkennzeichnung A.6), den
* Formular, API v1 und Admin-Upload gleichermaßen nutzen:
*
* 1. Grenzen prüfen (E-B6), Bytes einmal lesen, SHA-256 bilden.
* 2. Beweisoriginal auf den privaten Originalpfad schreiben (E9, F05).
* 3. Herkunftsprüfung auf dieselben Bytes (A.5); KI-Vorab-Check (Block K)
* davor auf den Quellbytes — eine Ablehnung speichert nichts.
* 4. Derivate über ImageService; bei Fehler alles zurückrollen.
* 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund
* getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor.
* 6. Transaktion mit Sperre auf der PM: erlaubter Status (Audit N07),
* ein Titelbild, Bildzeile, Bestätigung (außer Admin-Upload),
* Regelversion.
* 7. DB-Fehler → Dateien wieder löschen. Nachgelagerte Schritte (Cache)
* laufen erst nach dem Commit und außerhalb der Bereinigung (Audit N08).
*
* Für Newsroom-Bilder (Block J) wird das Subjekt verallgemeinert; Phase 1
* kennt nur PressRelease.
*/
class PressReleaseImageUploader
{
public function __construct(
private readonly ImageService $imageService,
private readonly ImageProvenanceInspector $inspector,
private readonly ImageRightsTextRenderer $textRenderer,
private readonly ImagePrecheckService $precheck,
private readonly PublishedImageSnapshot $snapshot,
) {}
/**
* Vorprüfung nach der Dateiauswahl (B.2): Grenzen und Herkunft, ohne
* etwas zu speichern. Das Ergebnis belegt das Formular vor; beim
* Speichern wird auf den finalen Bytes neu gerechnet.
*/
public function preflight(UploadedFile $upload): ProvenanceResult
{
$this->assertWithinLimits($upload);
return $this->inspector->inspect($upload->getRealPath());
}
/**
* KI-Vorab-Check nach der Dateiauswahl (Block K, DU 12.3). Liefert null,
* wenn der Check aus ist. Eine Ablehnung wirft sofort mit dem Grund in
* 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, ?string $sha256 = null): ?ImagePrecheckResult
{
// 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;
}
$this->assertNotRejected($pressRelease, $result, null);
return $result;
}
/**
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
* Rejected, Admin: alles außer Archived). Sie wird unter der Sperre
* erneut geprüft, damit ein Statuswechsel während Vorab-Check und
* Variantenerzeugung den Upload nicht überholt (Audit N07). Null = keine
* Regel (nur für Aufrufer, die den Status selbst sperren).
*
* @param list<PressReleaseStatus>|null $allowedStatuses
*/
public function store(
UploadedFile $upload,
PressRelease $pressRelease,
ImageSubmission $submission,
ImageUploadActor $actor,
?string $expectedSha256 = null,
?string $agbUrl = null,
?ImagePrecheckResult $precheck = null,
?array $allowedStatuses = null,
): PressReleaseImage {
$this->assertWithinLimits($upload);
$sourcePath = $upload->getRealPath();
$sha256 = (string) hash_file('sha256', $sourcePath);
// Bindung an die geprüfte Datei (F09): Wer zwischen Auswahl und
// Speichern die Datei tauscht, bekommt keine alte Vorbelegung.
if ($expectedSha256 !== null && ! hash_equals($expectedSha256, $sha256)) {
throw new ImageUploadException(__('Die Datei hat sich seit der Prüfung geändert. Bitte wählen Sie das Bild erneut aus.'));
}
$legalText = $this->resolveLegalText($submission, $actor);
// Vorab-Check vor jedem Schreiben (K.3): Ablehnung → nichts gespeichert.
$precheck = $this->precheck->resolve($sourcePath, $sha256, $precheck);
$precheckEvaluation = $precheck !== null ? $this->assertNotRejected($pressRelease, $precheck, $submission->aiDeclaration) : null;
$originalsDisk = (string) config('images.originals_disk', 'local');
$originalPath = sprintf(
'%s/%d/%s.%s',
config('images.originals_directory'),
$pressRelease->id,
Str::uuid()->toString(),
$this->extensionFor($upload),
);
$disk = Storage::disk($originalsDisk);
$stream = fopen($sourcePath, 'rb');
if ($stream === false || ! $disk->put($originalPath, $stream)) {
throw new RuntimeException('Das Original konnte nicht gesichert werden.');
}
if (is_resource($stream)) {
fclose($stream);
}
$stored = null;
$publishedNow = false;
try {
$provenance = $this->inspector->inspect($disk->path($originalPath));
if (! hash_equals($sha256, $provenance->fileSha256)) {
throw new RuntimeException('Hash von Original und Prüfung stimmen nicht überein.');
}
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
$stored = $this->imageService->storePressReleaseImage(
$upload,
$pressRelease->id,
focus: $submission->focus,
noCrop: $submission->noCrop,
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, &$publishedNow): PressReleaseImage {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
->lockForUpdate()
->firstOrFail();
if ($allowedStatuses !== null && ! in_array($locked->status, $allowedStatuses, true)) {
throw new ImageUploadException(__('Der Status der Meldung hat sich während des Uploads geändert. Bitte laden Sie die Seite neu.'));
}
if ($locked->images()->exists()) {
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'],
'variants' => $stored['variants'],
'title' => $submission->title ?: null,
'description' => $submission->description ?: null,
'copyright' => $submission->copyright,
'credit_source' => $this->creditSource($submission, $actor),
'license_url' => $submission->licenseUrl ?: null,
'source_url' => $submission->sourceUrl ?: null,
'rights_confirmed_at' => $actor->confirmsImmediately() ? now() : null,
'ai_declaration' => $effective['declaration'],
'ai_declaration_user' => $submission->aiDeclaration,
'ai_declaration_source' => $effective['source'],
'ai_declaration_locked' => $effective['locked'],
'provenance_status' => $provenance->status,
'provenance' => $provenance->toArray() + ($effective['deviated'] ? ['deviated_from_prefill' => true] : []),
'provenance_checked_at' => now(),
'provenance_tool_version' => $provenance->toolVersion,
'precheck' => $precheckEvaluation !== null ? $this->precheck->storedPayload($precheck, $precheckEvaluation) : null,
'precheck_outcome' => $precheckEvaluation?->outcome,
'precheck_review_required_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' => $publishedNow ? now() : null,
'customer_confirmation_requested_at' => $actor->confirmsImmediately() ? null : now(),
'rules_version' => config('images.rules_version'),
'focus_x' => $submission->focus['x'] ?? null,
'focus_y' => $submission->focus['y'] ?? null,
'no_crop' => $submission->noCrop,
'is_preview' => true,
'sort_order' => 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
if ($precheckEvaluation !== null) {
$this->precheck->auditStored($image, $precheck, $precheckEvaluation);
}
if ($actor->confirmsImmediately() && $legalText !== null) {
$this->recordConfirmation($image, $locked, $actor, $legalText, $agbUrl);
}
return $image;
});
} catch (Throwable $exception) {
// Kein Rollback für Dateien: Was geschrieben wurde, wird
// ausdrücklich wieder entfernt (Feedback F09). Nur Fehler VOR
// bzw. IM Commit landen hier — danach gehören die Dateien zur
// gespeicherten Bildzeile (Audit N08).
if ($stored !== null) {
$this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']);
}
$disk->delete($originalPath);
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) {
$this->forgetPublicPagesQuietly();
}
return $image;
}
/**
* Nachträgliche Bestätigung eines bestehenden Bildes durch den
* Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung
* 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, ?string $expectedSha256 = null): ImageRightsConfirmation
{
$pressRelease = $image->pressRelease;
if (
! $actor->confirmsImmediately()
|| $pressRelease === null
|| $actor->user === null
|| (int) $pressRelease->user_id !== (int) $actor->user->getKey()
) {
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
}
$legalText = $this->resolveLegalText(
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
$actor,
);
if ($legalText === null) {
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
}
[$confirmation, $image] = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl, $expectedSha256): array {
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
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->status === PressReleaseStatus::Published) {
$this->captureSnapshotQuietly($image, $pressRelease);
}
if ($pressRelease->published_at !== null) {
$this->forgetPublicPagesQuietly();
}
return $confirmation;
}
/**
* Nachgelagerte Schritte nach erfolgreichem Commit dürfen die gespeicherte
* Bildzeile nicht mehr gefährden: Fehler werden gemeldet, nicht geworfen
* (Audit N08). Der Cache verfällt spätestens mit seiner TTL.
*/
private function forgetPublicPagesQuietly(): void
{
try {
WebCaches::forgetPublicPages();
} catch (Throwable $exception) {
report($exception);
}
}
private function captureSnapshotQuietly(PressReleaseImage $image, PressRelease $pressRelease): void
{
try {
$this->snapshot->capture($image, $pressRelease);
} catch (Throwable $exception) {
report($exception);
}
}
/**
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
* Ungesicherte Metadaten (Phase 1) belegen nur vor — weicht der Kunde
* nach unten ab, wird das vermerkt, nicht verhindert.
*
* @return array{declaration: AiImageDeclaration|null, source: AiDeclarationSource|null, locked: bool, deviated: bool}
*/
private function effectiveDeclaration(?AiImageDeclaration $userChoice, ProvenanceResult $provenance): array
{
$enforced = $provenance->enforcedAiDeclaration();
if ($enforced !== null) {
$effective = AiImageDeclaration::strictest($enforced, $userChoice);
return [
'declaration' => $effective,
'source' => $effective === $userChoice && $userChoice->rank() > $enforced->rank()
? AiDeclarationSource::Customer
: AiDeclarationSource::C2paVerified,
'locked' => true,
'deviated' => false,
];
}
$suggestion = $provenance->suggestedAiDeclaration();
$deviated = $suggestion !== null && $userChoice !== null && $userChoice->rank() < $suggestion->rank();
return [
'declaration' => $userChoice,
'source' => $userChoice === null
? null
: ($suggestion !== null && $suggestion === $userChoice ? AiDeclarationSource::IptcPrefill : AiDeclarationSource::Customer),
'locked' => false,
'deviated' => $deviated,
];
}
/**
* Auswertung des Vorab-Checks (K.2). Die Kundenangabe fließt nur in den
* Hinweis „wirkt KI-generiert" ein; die Ablehnung hängt allein am Befund.
*/
private function assertNotRejected(PressRelease $pressRelease, ImagePrecheckResult $result, ?AiImageDeclaration $userChoice): ImagePrecheckEvaluation
{
$evaluation = $this->precheck->evaluate($result, $userChoice);
if ($evaluation->outcome === ImagePrecheckOutcome::Reject) {
$this->precheck->auditRejected($pressRelease, $result, $evaluation);
throw new ImageUploadException((string) $evaluation->rejectionMessage());
}
return $evaluation;
}
private function creditSource(ImageSubmission $submission, ImageUploadActor $actor): CreditSource
{
if ($submission->creditFromSuggestion) {
return CreditSource::Metadata;
}
return $actor->channel?->value === 'api' ? CreditSource::Api : CreditSource::Customer;
}
private function resolveLegalText(ImageSubmission $submission, ImageUploadActor $actor): ?LegalText
{
if (! $actor->confirmsImmediately()) {
return null;
}
if (! $submission->rightsConfirmed) {
throw new ImageUploadException(__('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'), 'rights_confirmed');
}
$current = $this->textRenderer->current();
if ($current === null) {
throw new RuntimeException('Kein aktiver Bestätigungstext hinterlegt (LegalTextSeeder).');
}
if ($submission->legalTextKey !== null && $submission->legalTextKey !== $current->key) {
throw new ImageUploadException(
__('Der Bestätigungstext wurde aktualisiert (aktuell :key). Bitte lesen und bestätigen Sie die neue Fassung.', ['key' => $current->key]),
'rights_confirmation_text_version',
);
}
return $current;
}
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): ImageRightsConfirmation
{
$variables = $this->textRenderer->variables($agbUrl);
return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $pressRelease->getKey(),
'company_id' => $pressRelease->company_id,
'user_id' => $actor->user?->getKey(),
'personal_access_token_id' => $actor->tokenId,
'channel' => $actor->channel,
'legal_text_id' => $legalText->getKey(),
'legal_text_key' => $legalText->key,
'text_snapshot' => $legalText->render($variables),
'agb_version' => $variables['agb_version'],
'locale' => $legalText->locale,
'file_sha256' => $image->original_sha256,
'declaration_snapshot' => [
'copyright' => $image->copyright,
'credit_source' => $image->credit_source?->value,
'license_url' => $image->license_url,
'source_url' => $image->source_url,
'ai_declaration_user' => $image->ai_declaration_user?->value,
'ai_declaration' => $image->ai_declaration?->value,
'ai_declaration_source' => $image->ai_declaration_source?->value,
'locked' => $image->ai_declaration_locked,
'provenance_status' => $image->provenance_status?->value,
'provenance' => $image->provenance,
],
'precheck_snapshot' => $image->precheck,
'ip_hmac' => ImageRightsConfirmation::hashIp($actor->ip),
'user_agent' => $actor->userAgent !== null ? Str::limit($actor->userAgent, 250, '') : null,
'confirmed_at' => now(),
'created_at' => now(),
]);
}
private function assertWithinLimits(UploadedFile $upload): void
{
$maxBytes = (int) config('images.max_bytes');
if ($upload->getSize() > $maxBytes) {
throw new ImageUploadException(__('Das Bild ist größer als :mb MB.', ['mb' => (int) round($maxBytes / 1024 / 1024)]));
}
if (! in_array($upload->getMimeType(), ImageService::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
throw new ImageUploadException(__('Bitte laden Sie ein JPG-, PNG- oder WebP-Bild hoch.'));
}
$size = @getimagesize($upload->getRealPath());
$maxSide = (int) config('images.max_pixels_per_side');
if ($size === false) {
throw new ImageUploadException(__('Die Datei konnte nicht als Bild gelesen werden.'));
}
if ($size[0] > $maxSide || $size[1] > $maxSide) {
throw new ImageUploadException(__('Das Bild darf höchstens :px Pixel je Seite haben.', ['px' => number_format($maxSide, 0, ',', '.')]));
}
}
private function extensionFor(UploadedFile $upload): string
{
return match ($upload->getMimeType()) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
}
}