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|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', }; } }