presseportale/app/Services/PressRelease/Images/PressReleaseImageUploader.php
Kevin Adametz 075d0d931f legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)
- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

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

372 lines
16 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 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).
* 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.');
}
$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): 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.'));
}
$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;
});
// Ö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) {
WebCaches::forgetPublicPages();
}
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;
}
}
/**
* 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.
*/
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation
{
if (! $actor->confirmsImmediately()) {
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 || $image->original_sha256 === null) {
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
}
$confirmation = DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
$confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl);
$image->forceFill(['rights_confirmed_at' => now()])->save();
return $confirmation;
});
if ($image->pressRelease?->published_at !== null) {
WebCaches::forgetPublicPages();
}
return $confirmation;
}
/**
* 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): 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,
],
'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',
};
}
}