Decision-Update Abschnitt 12 (E15, S24): Sichtung auf offensichtliche Probleme, keine Rechteprüfung, öffentlich weiterhin „automatisierte Prüfung". - ImagePrecheckManager mit openai- und deterministic-Treiber (Muster der Text-Prüfstrecke), Bild auf 1024 px, Metadaten gestrippt, JPEG als Data-URI, Antwortschema 12.4; Fehler → Ausgang `error`, durchlassen mit Vermerk. - Auswertung 12.3: reject (Stock-Wasserzeichen, Nacktheit/Gewalt, Dokument ab 0,85), queue (Screenshot, fremdes Logo, Kennzeichen ab 0,6 sowie Ablehnungskategorien 0,6–0,85), hint (Personen, „wirkt KI" nur bei „ohne KI", technischer Mangel). Stufe 1 wertet nur das Wasserzeichen; die KI setzt nie die Kennzeichnung (E9). Herabstufung per Config (12.8). - Uploader: Check vor jedem Schreiben, Ablehnung speichert nichts und landet als ki_audits `image_precheck` an der PM; Hash-Bindung zwischen Vorprüfung im Formular und Speichern; Spalten precheck*, Snapshot `precheck_snapshot` im Bestätigungsprotokoll, Archiv-Payload erweitert. - Queue: Bild zurückgehalten (withheld_at), Meldung läuft weiter; Admin entscheidet mit Vermerk (Freigabe/Ablehnung → ki_audits `image_precheck_review`); Ansicht „Bild-Sichtung" in der PM-Liste, Sichtungsblock auf der PM-Seite, Systemstatus-Zähler. - Formular v2: Ablehnungsgrund unter dem Dateifeld, Hinweise nach der Dateiauswahl, „wird kurz gesichtet" an der Karte; API 422 mit Grund, Warnung `image_under_review`. - Config images.precheck (IMAGE_PRECHECK aus, STAGE 1); 17 Tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
428 lines
18 KiB
PHP
428 lines
18 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\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: 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,
|
|
private readonly ImagePrecheckService $precheck,
|
|
) {}
|
|
|
|
/**
|
|
* 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): ?ImagePrecheckResult
|
|
{
|
|
$result = $this->precheck->run($upload->getRealPath(), (string) hash_file('sha256', $upload->getRealPath()));
|
|
|
|
if ($result === null) {
|
|
return null;
|
|
}
|
|
|
|
$this->assertNotRejected($pressRelease, $result, null);
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function store(
|
|
UploadedFile $upload,
|
|
PressRelease $pressRelease,
|
|
ImageSubmission $submission,
|
|
ImageUploadActor $actor,
|
|
?string $expectedSha256 = null,
|
|
?string $agbUrl = null,
|
|
?ImagePrecheckResult $precheck = 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;
|
|
|
|
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): 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,
|
|
'precheck' => $precheckEvaluation !== null ? $this->precheck->storedPayload($precheck, $precheckEvaluation) : null,
|
|
'precheck_outcome' => $precheckEvaluation?->outcome,
|
|
'precheck_review_required_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
|
|
'withheld_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
|
|
'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 ($precheckEvaluation !== null) {
|
|
$this->precheck->auditStored($image, $precheck, $precheckEvaluation);
|
|
}
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
};
|
|
}
|
|
}
|