presseportale/app/Services/PressRelease/Images/PressReleaseImageUploader.php
Kevin Adametz e9bdff3ad3 images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet
- legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations
- image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads
- ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error
- PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung
- ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:22:05 +00:00

327 lines
14 KiB
PHP

<?php
namespace App\Services\PressRelease\Images;
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\CreditSource;
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\ProvenanceResult;
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).
* 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: ein Titelbild, Bildzeile,
* Bestätigung (außer Admin-Upload), Regelversion.
* 7. DB-Fehler → Dateien wieder löschen.
*
* 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,
) {}
/**
* 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());
}
public function store(
UploadedFile $upload,
PressRelease $pressRelease,
ImageSubmission $submission,
ImageUploadActor $actor,
?string $expectedSha256 = null,
?string $agbUrl = 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);
$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;
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.');
}
$stored = $this->imageService->storePressReleaseImage(
$upload,
$pressRelease->id,
focus: $submission->focus,
noCrop: $submission->noCrop,
);
return DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
->lockForUpdate()
->firstOrFail();
if ($locked->images()->exists()) {
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
}
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
$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,
'original_disk' => $originalsDisk,
'original_path' => $originalPath,
'original_sha256' => $sha256,
'published_ever_at' => $locked->published_at !== null ? 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 ($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).
if ($stored !== null) {
$this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']);
}
$disk->delete($originalPath);
throw $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,
];
}
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): void
{
$variables = $this->textRenderer->variables($agbUrl);
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,
],
'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',
};
}
}