diff --git a/app/Enums/ConversionSource.php b/app/Enums/ConversionSource.php index c80ed404..efaafbb1 100644 --- a/app/Enums/ConversionSource.php +++ b/app/Enums/ConversionSource.php @@ -23,6 +23,9 @@ enum ConversionSource: string case ProfileClaim = 'profile_claim'; + /** „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, E10). */ + case MailImageConfirmation = 'mail_image_confirmation'; + case Organic = 'organic'; public function label(): string @@ -32,6 +35,7 @@ enum ConversionSource: string self::MailReject => 'Ablehnungsmail', self::MailReview => 'Prüfhinweis-Mail', self::ProfileClaim => 'Hinweis auf der Newsroom-Seite', + self::MailImageConfirmation => 'Mail „Bild bestätigen"', self::Organic => 'ohne zugeordnete Herkunft', }; } diff --git a/app/Mail/PressReleaseImageConfirmationRequested.php b/app/Mail/PressReleaseImageConfirmationRequested.php new file mode 100644 index 00000000..6168af78 --- /dev/null +++ b/app/Mail/PressReleaseImageConfirmationRequested.php @@ -0,0 +1,44 @@ +pressRelease; + $owner = $pressRelease?->user; + + if ($pressRelease === null || $owner === null || blank($owner->email)) { + return false; + } + + $link = $this->magicLinks->createLoginLink( + $owner, + $requestedIp, + self::LINK_TTL_MINUTES, + ConversionSource::MailImageConfirmation, + [ + 'company_id' => $pressRelease->company_id, + 'press_release_id' => $pressRelease->id, + 'image_confirmation' => $image->id, + ], + ); + + if ($image->customer_confirmation_requested_at === null) { + $image->forceFill(['customer_confirmation_requested_at' => now()])->save(); + } + + Mail::to($owner->email)->queue( + (new PressReleaseImageConfirmationRequested( + $pressRelease, + $image, + route('magic-links.consume', ['token' => $link['plain_token']]), + $link['expires_at'], + $owner->name, + ))->afterCommit(), + ); + + return true; + } + + /** + * Bestätigung des Kontoinhabers im Konto (Kanal magic_link). + */ + public function confirm(PressReleaseImage $image, User $customer, ?string $legalTextKey, ?string $ip, ?string $userAgent, ?string $agbUrl = null): ImageRightsConfirmation + { + return $this->uploader->confirmExisting( + $image, + new ImageUploadActor($customer, ConfirmationChannel::MagicLink, null, $ip, $userAgent), + $legalTextKey, + $agbUrl, + ); + } +} diff --git a/app/Services/PressRelease/Images/PressReleaseImageRemover.php b/app/Services/PressRelease/Images/PressReleaseImageRemover.php new file mode 100644 index 00000000..e191c01c --- /dev/null +++ b/app/Services/PressRelease/Images/PressReleaseImageRemover.php @@ -0,0 +1,52 @@ +imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); + + if ($image->wasEverPublished() || $image->isLegacyRules()) { + $image->delete(); + + return; + } + + DB::transaction(function () use ($image): void { + foreach ($image->rightsConfirmations()->get() as $confirmation) { + $confirmation->purgeForUnpublished(); + } + + if ($image->original_path !== null) { + Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->original_path); + } + + if ($image->published_snapshot_path !== null) { + Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->published_snapshot_path); + } + + $image->forceDelete(); + }); + } +} diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index 7ba45afc..74af312c 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -182,6 +182,34 @@ class PressReleaseImageUploader } } + /** + * 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'); + } + + return 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; + }); + } + /** * Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt * die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen. @@ -255,11 +283,11 @@ class PressReleaseImageUploader return $current; } - private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): void + private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): ImageRightsConfirmation { $variables = $this->textRenderer->variables($agbUrl); - ImageRightsConfirmation::query()->create([ + return ImageRightsConfirmation::query()->create([ 'subject_type' => $image->getMorphClass(), 'subject_id' => $image->getKey(), 'press_release_id' => $pressRelease->getKey(), diff --git a/resources/views/emails/press-release/image-confirmation.blade.php b/resources/views/emails/press-release/image-confirmation.blade.php new file mode 100644 index 00000000..d1c0fd62 --- /dev/null +++ b/resources/views/emails/press-release/image-confirmation.blade.php @@ -0,0 +1,35 @@ + +

{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}

+ +

+ für Ihre Pressemitteilung wurde von unserem Team ein Titelbild hinterlegt. + Damit es veröffentlicht werden kann, brauchen wir Ihre Bestätigung, dass Sie + zur Veröffentlichung des Bildes berechtigt sind und die Angaben dazu stimmen. +

+ +

{{ $pressRelease->title }}

+ +
+
Bildnachweis
+
{{ $image->copyright ?: '—' }}
+
KI-Kennzeichnung
+
{{ $image->ai_declaration?->label() ?? 'noch offen' }}
+
+ + Bild ansehen und bestätigen + +

+ Der Link meldet Sie direkt in Ihrem Konto an und ist gültig bis + {{ $expiresAt->format('d.m.Y H:i') }} Uhr. Danach können Sie + sich jederzeit regulär anmelden und die Bestätigung auf der Seite der + Pressemitteilung nachholen. +

+ +
+

+ Ohne Ihre Bestätigung bleibt die Pressemitteilung mit dem Platzhalter-Bild. + Wenn das Bild nicht von Ihnen stammt oder die Angaben nicht stimmen, + antworten Sie bitte auf diese E-Mail. +

+
+
diff --git a/resources/views/livewire/admin/press-releases/edit.blade.php b/resources/views/livewire/admin/press-releases/edit.blade.php index 5292998f..3f9124c8 100644 --- a/resources/views/livewire/admin/press-releases/edit.blade.php +++ b/resources/views/livewire/admin/press-releases/edit.blade.php @@ -691,7 +691,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl {{-- 5) MEDIEN --}} - + @if (config('images.rights_v2')) + + @else + + @endif {{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT Datei-Uploads erfordern eine vollständige Sicherheitsprüfung diff --git a/resources/views/livewire/components/press-release-images-manager-v2.blade.php b/resources/views/livewire/components/press-release-images-manager-v2.blade.php new file mode 100644 index 00000000..b260838a --- /dev/null +++ b/resources/views/livewire/components/press-release-images-manager-v2.blade.php @@ -0,0 +1,738 @@ +pressReleaseId = $pressReleaseId; + } + + public function openUploadForm(): void + { + $this->isUploadFormOpen = true; + } + + public function updatedIsUploadFormOpen(bool $isOpen): void + { + if (!$isOpen) { + $this->resetUploadForm(); + $this->resetErrorBag(); + } + } + + /** + * Nach der Dateiauswahl: Grenzen prüfen, Herkunft lesen, Formular + * vorbelegen. Ein Dateiwechsel setzt Vorbelegung und Schalter zurück (B.2). + */ + public function updatedNewImage(PressReleaseImageUploader $uploader): void + { + $this->resetProvenance(); + $this->newRightsConfirmed = false; + $this->resetErrorBag(); + + if (!$this->newImage) { + return; + } + + $this->validateOnly('newImage', ['newImage' => $this->imageRules()]); + + try { + $result = $uploader->preflight($this->newImage); + } catch (ImageUploadException $exception) { + $this->addError('newImage', $exception->getMessage()); + $this->reset('newImage'); + + return; + } + + $this->provenanceStatus = $result->status->value; + $this->provenanceSha = $result->fileSha256; + $this->creditSuggestion = $result->creditSuggestion(); + $this->aiSuggestion = $result->suggestedAiDeclaration()?->value; + $this->enhancementHint = (bool) $result->derived['enhancement_hint']; + + // Nachweis vorbelegen: aus den Metadaten, sonst Firmenname — nur, + // solange der Nutzer nichts Eigenes eingetragen hat. + $suggested = $this->creditSuggestion ?? $this->companyName(); + + if ($this->newCopyright === '' || $this->newCopyright === $this->autoCopyright) { + $this->newCopyright = $suggested; + $this->autoCopyright = $suggested; + } + + if ($this->newAiDeclaration === '' && $this->aiSuggestion !== null) { + $this->newAiDeclaration = $this->aiSuggestion; + } + } + + public function closeUploadForm(): void + { + $this->resetUploadForm(); + $this->resetErrorBag(); + + $this->isUploadFormOpen = false; + } + + public function removeNewImage(): void + { + $this->reset('newImage'); + $this->resetProvenance(); + $this->newRightsConfirmed = false; + $this->resetErrorBag('newImage'); + } + + public function newImagePreviewUrl(): ?string + { + if (!is_object($this->newImage) || !method_exists($this->newImage, 'temporaryUrl')) { + return null; + } + + try { + return $this->newImage->temporaryUrl(); + } catch (\Throwable) { + return null; + } + } + + public function saveImage(PressReleaseImageUploader $uploader, ImageRightsTextRenderer $texts, CustomerImageConfirmation $customerConfirmation): void + { + $pressRelease = $this->getPressRelease(); + $this->authorize('update', $pressRelease); + + if (!$this->canChangeImages($pressRelease)) { + $this->addError('newImage', __('Das Titelbild kann nur bei Entwürfen oder abgelehnten PMs geändert werden.')); + + return; + } + + if ($this->titleImageFor($pressRelease) !== null) { + $this->addError('newImage', __('Bitte löschen Sie zuerst das vorhandene Titelbild.')); + + return; + } + + $isAdmin = $this->actsAsAdmin(); + + $this->validate( + [ + 'newImage' => $this->imageRules(), + 'newCopyright' => ['required', 'string', 'max:255'], + 'newAiDeclaration' => ['required', Rule::enum(AiImageDeclaration::class)], + 'newTitle' => ['nullable', 'string', 'max:120'], + 'newDescription' => ['nullable', 'string', 'max:500'], + 'newLicenseUrl' => ['nullable', 'url:http,https', 'max:2048'], + 'newSourceUrl' => ['nullable', 'url:http,https', 'max:2048'], + 'newRightsConfirmed' => [$isAdmin ? 'nullable' : 'accepted'], + 'focus' => ['required', Rule::in(array_keys($this->focusOptions()))], + ], + [ + 'newCopyright.required' => __('Bitte geben Sie an, wer das Bild erstellt hat oder die Rechte hält.'), + 'newAiDeclaration.required' => __('Bitte wählen Sie aus, ob das Bild mit KI erstellt oder bearbeitet wurde.'), + 'newRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'), + ], + ); + + $legalText = $texts->current(); + [$focusX, $focusY] = array_map('intval', explode(',', $this->focus)); + + $submission = new ImageSubmission( + copyright: trim($this->newCopyright), + aiDeclaration: AiImageDeclaration::from($this->newAiDeclaration), + rightsConfirmed: $this->newRightsConfirmed, + legalTextKey: $legalText?->key, + title: $this->newTitle ?: null, + description: $this->newDescription ?: null, + licenseUrl: $this->newLicenseUrl ?: null, + sourceUrl: $this->newSourceUrl ?: null, + focus: ['x' => $focusX, 'y' => $focusY], + noCrop: $this->noCrop, + creditFromSuggestion: $this->creditSuggestion !== null && trim($this->newCopyright) === $this->creditSuggestion, + ); + + $user = auth()->user(); + $actor = $isAdmin + ? ImageUploadActor::admin($user, request()->ip(), request()->userAgent()) + : ImageUploadActor::form($user, request()->ip(), request()->userAgent()); + + try { + $image = $uploader->store( + $this->newImage, + $pressRelease, + $submission, + $actor, + expectedSha256: $this->provenanceSha, + agbUrl: $this->agbUrl(), + ); + } catch (ImageUploadException $exception) { + $this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage()); + + return; + } + + if ($isAdmin) { + $customerConfirmation->request($image, request()->ip()); + } + + $this->resetUploadForm(); + $this->isUploadFormOpen = false; + + $this->dispatch('title-image-changed'); + + Flux::toast( + text: $isAdmin + ? __('Titelbild hochgeladen. Der Kunde wurde per E-Mail um die Bestätigung der Bildrechte gebeten.') + : __('Titelbild hochgeladen.'), + variant: 'success', + ); + } + + /** + * Admin: Bestätigungslink erneut an den Kontoinhaber schicken. + */ + public function resendConfirmationRequest(CustomerImageConfirmation $customerConfirmation): void + { + $pressRelease = $this->getPressRelease(); + $this->authorize('update', $pressRelease); + + $image = $this->titleImageFor($pressRelease); + + if ($image === null || !$image->awaitsCustomerConfirmation() || !$this->actsAsAdmin()) { + return; + } + + $sent = $customerConfirmation->request($image, request()->ip()); + + Flux::toast( + text: $sent ? __('Bestätigungslink erneut gesendet.') : __('Kein Empfänger für den Bestätigungslink hinterlegt.'), + variant: $sent ? 'success' : 'warning', + ); + } + + public function remove(int $imageId, PressReleaseImageRemover $remover): void + { + $pressRelease = $this->getPressRelease(); + $this->authorize('update', $pressRelease); + + if (!$this->canChangeImages($pressRelease)) { + return; + } + + $image = $pressRelease->images()->whereKey($imageId)->first(); + + if (!$image) { + return; + } + + $remover->remove($image); + + $this->dispatch('title-image-changed'); + + Flux::toast(text: __('Titelbild entfernt.'), variant: 'success'); + } + + public function with(ImageRightsTextRenderer $texts): array + { + $pressRelease = $this->getPressRelease(); + $titleImage = $this->titleImageFor($pressRelease); + $legalText = $texts->current(); + $agbUrl = $this->agbUrl(); + + return [ + 'titleImage' => $titleImage, + 'latestConfirmation' => $titleImage?->latestRightsConfirmation, + 'awaitsCustomerConfirmation' => $titleImage?->awaitsCustomerConfirmation() ?? false, + 'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease), + 'isAdminActor' => $this->actsAsAdmin(), + 'companyName' => $this->companyName(), + 'legalTextKey' => $legalText?->key, + 'legalLong' => $legalText ? $texts->renderLong($legalText, $agbUrl) : null, + 'legalShort' => $legalText ? $texts->renderShort($legalText, $agbUrl) : null, + 'provenanceState' => $this->provenanceState(), + 'aiOptions' => AiImageDeclaration::cases(), + 'focusOptions' => $this->focusOptions(), + 'maxMb' => (int) round((int) config('images.max_bytes') / 1024 / 1024), + ]; + } + + /** + * @return array + */ + private function imageRules(): array + { + return [ + 'required', + 'image', + 'mimes:jpeg,jpg,png,webp', + 'max:' . (int) ((int) config('images.max_bytes') / 1024), + ]; + } + + /** + * Zustand des Prüfhinweises im Formular (B.1): B keine Daten, C + * vorbelegt, D nicht prüfbar. Zustand A (geprüfte Signatur) kommt mit + * Phase 2. + * + * @return array{key: string, title: string, text: string, tone: string}|null + */ + private function provenanceState(): ?array + { + if ($this->provenanceStatus === null) { + return null; + } + + return match (ImageProvenanceStatus::from($this->provenanceStatus)) { + ImageProvenanceStatus::Error => [ + 'key' => 'error', + 'title' => __('Herkunftsdaten konnten nicht geprüft werden.'), + 'text' => __('Bitte machen Sie die Angaben selbst. Ihr Upload wird dadurch nicht blockiert.'), + 'tone' => 'amber', + ], + ImageProvenanceStatus::UnsignedMetadata => [ + 'key' => 'prefilled', + 'title' => __('Angaben aus den Metadaten der Datei vorbelegt.'), + 'text' => $this->aiSuggestion !== null + ? __('Die Datei nennt als Quelle „:type". Sie können die Auswahl ändern.', ['type' => AiImageDeclaration::from($this->aiSuggestion)->label()]) + : ($this->enhancementHint + ? __('Die Datei ist als algorithmisch nachbearbeitet gekennzeichnet (z. B. Schärfung). Das allein ist keine KI-Bearbeitung.') + : __('Bitte prüfen Sie den vorgeschlagenen Bildnachweis.')), + 'tone' => 'zinc', + ], + ImageProvenanceStatus::ValidatedTrusted => [ + 'key' => 'verified', + 'title' => __('Herkunftsdaten erkannt (Content Credentials).'), + 'text' => __('Die Kennzeichnung ist fest hinterlegt und kann nicht abgeschwächt werden.'), + 'tone' => 'green', + ], + default => [ + 'key' => 'absent', + 'title' => __('Keine Herkunftsdaten in der Datei gefunden.'), + 'text' => __('Bitte machen Sie die Angaben selbst.'), + 'tone' => 'zinc', + ], + }; + } + + /** + * @return array + */ + private function focusOptions(): array + { + return [ + '0,0' => __('oben links'), + '50,0' => __('oben'), + '100,0' => __('oben rechts'), + '0,50' => __('links'), + '50,50' => __('Mitte'), + '100,50' => __('rechts'), + '0,100' => __('unten links'), + '50,100' => __('unten'), + '100,100' => __('unten rechts'), + ]; + } + + private function agbUrl(): string + { + return route('agb', ['edition' => 'de']); + } + + private function companyName(): string + { + return (string) ($this->getPressRelease()->company()->withoutGlobalScope(PortalScope::class)->value('name') ?? ''); + } + + private function actsAsAdmin(): bool + { + $user = auth()->user(); + + return $user !== null && $user->canAccessAdmin() && $this->getPressRelease()->user_id !== $user->getKey(); + } + + private function getPressRelease(): PressRelease + { + return PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($this->pressReleaseId); + } + + private function canChangeImages(PressRelease $pressRelease): bool + { + if (auth()->user()?->canAccessAdmin()) { + return !in_array($pressRelease->status, [PressReleaseStatus::Archived], true); + } + + return in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true); + } + + private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage + { + return $pressRelease->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(); + } + + private function resetProvenance(): void + { + $this->provenanceStatus = null; + $this->provenanceSha = null; + $this->creditSuggestion = null; + $this->aiSuggestion = null; + $this->enhancementHint = false; + } + + private function resetUploadForm(): void + { + $this->reset(['newImage', 'newCopyright', 'newAiDeclaration', 'newTitle', 'newDescription', 'newLicenseUrl', 'newSourceUrl', 'newRightsConfirmed', 'noCrop', 'focus', 'showLegalDetails', 'autoCopyright']); + $this->resetProvenance(); + } +}; ?> + + +
+ {{ __('Titelbild') }} + + {{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : __('gesetzt')) : __('Platzhalter aktiv') }} + +
+ + @if ($titleImage) +
+
+ @php + $titleImageUrl = + $titleImage->variantUrl('cover') ?? + ($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url())); + @endphp + + @if ($titleImageUrl) + {{ $titleImage->title ?? __('Titelbild') }} + @endif +
+ +
+
+
+
+ {{ $titleImage->title ?: __('Eigenes Titelbild') }} +
+ @if ($titleImage->copyright) +

+ {{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }} +

+ @elseif ($titleImage->author) +

© {{ $titleImage->author }}

+ @endif +
+ @if ($aiBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null)) + {{ $aiBadge }} + @endif + @if ($titleImage->ai_declaration_source) + {{ __('Kennzeichnung: :source', ['source' => $titleImage->ai_declaration_source->publicLabel()]) }} + @endif + @if ($titleImage->width && $titleImage->height) + {{ $titleImage->width }}×{{ $titleImage->height }} + @endif +
+ {{-- Bestätigungsstand (D.3): Kennung, AGB-Fassung, Zeitpunkt, Kanal. --}} + @if ($latestConfirmation) +

+ {{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', [ + 'date' => $latestConfirmation->confirmed_at->format('d.m.Y H:i'), + 'key' => $latestConfirmation->legal_text_key, + 'agb' => $latestConfirmation->agb_version ?? '–', + 'channel' => $latestConfirmation->channel->value, + ]) }} +

+ @elseif ($titleImage->isLegacyRules()) +

{{ __('Bestand ohne Bestätigungsprotokoll.') }}

+ @endif +
+ + @if ($canEdit) + + {{ __('Titelbild löschen') }} + + @endif +
+ + @if ($awaitsCustomerConfirmation) +
+ {{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }} + @if ($isAdminActor) + + {{ __('Bestätigungslink erneut senden') }} + + @endif +
+ @endif +
+
+ @elseif($canEdit) +
+
+ {{ __('Hier fehlt ein Titelbild') }} + + {{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }} + +
+ + + {{ __('Eigenes Titelbild hochladen') }} + +
+ + + @if ($isUploadFormOpen) +
+
+ {{ __('Titelbild hochladen') }} + + {{ __('Bild auswählen, Bildnachweis und KI-Kennzeichnung angeben, bestätigen — fertig.') }} + +
+ + {{-- ===== 1 · Bild ===== --}} +
+
+ 1 + {{ __('Bild') }} +
+ + @if (!$newImage) + + + + +
+ {{ __('Bitte laden Sie nur Bilder hoch, für die Sie die erforderlichen Nutzungsrechte besitzen. Bilder aus Google, Social Media, Messenger-Gruppen oder fremden Websites dürfen nicht ohne ausdrückliche Erlaubnis verwendet werden.') }} +
+ @else +
+
+ @if ($this->newImagePreviewUrl()) + {{ __('Vorschau des gewählten Titelbilds') }} + @else +
+ +
+ @endif +
+
+
+ {{ $newImage->getClientOriginalName() }} + · + {{ number_format($newImage->getSize() / 1048576, 2, ',', '.') }} MB +
+ + {{ __('Anderes Bild wählen') }} + +
+
+ + @if ($provenanceState) +
+ {{ $provenanceState['title'] }} + {{ $provenanceState['text'] }} + @if ($provenanceState['key'] !== 'absent') + + {{ __('Stimmen die Herkunftsdaten nicht? Schreiben Sie uns an :email.', ['email' => config('legal.contact_email')]) }} + + @endif +
+ @endif + +
+ + @foreach ($focusOptions as $value => $label) + {{ $label }} + @endforeach + + +
+ @endif + +
+ + @if ($newImage) + {{-- ===== 2 · Angaben ===== --}} +
+
+ 2 + {{ __('Bildnachweis & KI-Kennzeichnung') }} +
+ + + + + @foreach ($aiOptions as $aiImageOption) + + @endforeach + + + +
+ + + {{ __('Optionale Angaben') }} + — {{ __('Titel, Bildunterschrift, Lizenz, Quelle') }} + +
+
+ + +
+
+ + +
+
+
+
+ + {{-- ===== 3 · Bestätigung ===== --}} +
+
+ 3 + {{ __('Bestätigung') }} +
+ + @if ($isAdminActor) +
+ {{ __('Sie laden im Auftrag des Kunden hoch. Die Rechtebestätigung gibt der Kontoinhaber selbst ab: Er erhält nach dem Upload einen Link „Bild bestätigen". Bis dahin bleibt der Platzhalter aktiv.') }} +
+ @else + + + + + @if ($showLegalDetails && $legalLong) +
+ {{ $legalLong }} +
+ @endif + @endif +
+ @endif + +
+ + {{ __('Abbrechen') }} + @if ($newImage) + + {{ __('Hochladen & einsetzen') }} + @endif +
+
+ @endif +
+ @else +
+ + + {{ __('Noch kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }} +
+ @endif +
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 862edf02..82924c99 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -700,7 +700,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl @endif {{-- 7) MEDIEN — Image-Manager direkt eingebunden --}} - + @if (config('images.rights_v2')) + + @else + + @endif {{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT Datei-Uploads erfordern eine vollständige Sicherheitsprüfung diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 96d1eb51..1da16a2c 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -17,6 +17,9 @@ use App\Services\PressRelease\AiDeclarationRequiredException; use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\BoostService; +use App\Services\Image\ImageRightsTextRenderer; +use App\Services\PressRelease\Images\CustomerImageConfirmation; +use App\Services\PressRelease\Images\ImageUploadException; use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; @@ -105,6 +108,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends */ public bool $purchaseConfirmed = false; + /** + * „Bild bestätigen" (Bildkennzeichnung B.4, Decision-Update E10): Schalter + * für die Rechtebestätigung eines im Auftrag hochgeladenen Titelbilds. + */ + public bool $imageRightsConfirmed = false; + + public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void + { + $pr = $this->getMyPR(); + $this->authorize('update', $pr); + + $image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(); + + if ($image === null || ! $image->awaitsCustomerConfirmation()) { + return; + } + + $this->validate( + ['imageRightsConfirmed' => ['accepted']], + ['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')], + ); + + try { + $confirmation->confirm( + $image, + auth()->user(), + $texts->current()?->key, + request()->ip(), + request()->userAgent(), + route('agb', ['edition' => 'de']), + ); + } catch (ImageUploadException $exception) { + $this->addError('imageRightsConfirmed', $exception->getMessage()); + + return; + } + + $this->imageRightsConfirmed = false; + + Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success'); + } + public function mount(int $id): void { $this->id = $id; @@ -822,7 +867,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends 'categoryName' => $categoryName, 'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), - 'titleImage' => $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(), + 'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(), + 'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('update', $pr), + 'imageLegalShort' => ($imageLegalText = app(ImageRightsTextRenderer::class)->current()) + ? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de'])) + : null, + 'imageLegalLong' => $imageLegalText + ? app(ImageRightsTextRenderer::class)->renderLong($imageLegalText, route('agb', ['edition' => 'de'])) + : null, 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'canEdit' => auth()->user()->can('update', $pr) @@ -1710,6 +1762,40 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @endif + {{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}} + @if ($awaitsImageConfirmation) +
+
+ {{ __('Titelbild bestätigen') }} +
+
+

+ {{ __('Dieses Titelbild wurde von unserem Team für Sie hinterlegt. Es wird erst veröffentlicht, wenn Sie bestätigen, dass Sie zur Veröffentlichung berechtigt sind und die Angaben stimmen.') }} +

+
+
{{ __('Bildnachweis') }}
+
{{ $titleImage->copyright ?: '—' }}
+
{{ __('KI-Kennzeichnung') }}
+
{{ $titleImage->ai_declaration?->label() ?? '—' }}
+
+ + + @if ($imageLegalLong) +
+ + + {{ __('Vollständiger Wortlaut') }} + +
{{ $imageLegalLong }}
+
+ @endif +
+ {{ __('Bildrechte bestätigen') }} +
+
+
+ @endif + {{-- ============== INHALT ============== --}}
diff --git a/tests/Feature/Images/PressReleaseImageRightsV2Test.php b/tests/Feature/Images/PressReleaseImageRightsV2Test.php new file mode 100644 index 00000000..6b86460b --- /dev/null +++ b/tests/Feature/Images/PressReleaseImageRightsV2Test.php @@ -0,0 +1,297 @@ +seed(RolesAndPermissionsSeeder::class); + $this->seed(LegalDocumentSeeder::class); + $this->seed(LegalTextSeeder::class); + Storage::fake('public'); + Storage::fake('local'); + config()->set('images.rights_v2', true); +}); + +/** + * @return array{owner: User, pr: PressRelease, company: Company} + */ +function makeRightsV2Draft(): array +{ + $owner = User::factory()->create(['is_active' => true]); + $owner->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG']); + $owner->companies()->attach($company->id, ['role' => 'owner']); + + $pr = PressRelease::factory()->create([ + 'user_id' => $owner->id, + 'company_id' => $company->id, + 'category_id' => Category::factory()->create()->id, + 'portal' => $company->portal->value, + 'status' => 'draft', + ]); + + return compact('owner', 'pr', 'company'); +} + +function rightsV2Fixture(string $name): UploadedFile +{ + return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name))); +} + +test('the edit page embeds the v2 manager only while the flag is on', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->assertSeeLivewire('components.press-release-images-manager-v2'); + + config()->set('images.rights_v2', false); + + LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) + ->assertSeeLivewire('components.press-release-images-manager') + ->assertDontSeeLivewire('components.press-release-images-manager-v2'); +}); + +test('nachweis, kennzeichnung and the confirmation are the only required inputs', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', '') + ->call('saveImage') + ->assertHasErrors(['newCopyright', 'newAiDeclaration', 'newRightsConfirmed']) + ->assertHasNoErrors(['newTitle', 'newLicenseUrl', 'newSourceUrl']); + + expect($pr->images()->count())->toBe(0); +}); + +test('without metadata the company name is suggested as credit and the state says so', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->assertSet('newCopyright', 'Beispiel AG') + ->assertSet('provenanceStatus', 'absent') + ->assertSee('Keine Herkunftsdaten in der Datei gefunden.'); +}); + +test('iptc and xmp metadata prefill credit and ai declaration and can be changed', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) + ->assertSet('newCopyright', 'Musterfirma GmbH / Anna Beispiel') + ->assertSet('newAiDeclaration', '') + ->assertSee('Angaben aus den Metadaten der Datei vorbelegt.'); + + $component + ->set('newImage', rightsV2Fixture('xmp-ai-generated.jpg')) + ->assertSet('newCopyright', 'KI-Bild / Musterfirma') + ->assertSet('newAiDeclaration', 'ai_generated') + ->assertSee('Sie können die Auswahl ändern') + ->set('newAiDeclaration', 'original'); + + expect($component->get('newAiDeclaration'))->toBe('original'); +}); + +test('a manual credit survives a file change but the confirmation toggle does not', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('a.jpg', 640, 480)) + ->set('newCopyright', 'Eigener Nachweis') + ->set('newRightsConfirmed', true) + ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) + ->assertSet('newCopyright', 'Eigener Nachweis') + ->assertSet('newRightsConfirmed', false); +}); + +test('an unreadable file shows the error state and is not accepted', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', rightsV2Fixture('broken.jpg')) + ->assertHasErrors(['newImage']); +}); + +test('a valid upload stores the image under the new rules with a confirmation and shows it on the card', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->set('newTitle', 'Werkshalle') + ->set('newLicenseUrl', 'https://example.com/lizenz') + ->set('focus', '100,0') + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasNoErrors() + ->assertSet('isUploadFormOpen', false) + ->assertDispatched('title-image-changed') + ->assertSee('Rechte bestätigt am') + ->assertSee('img-confirm-v2'); + + $image = $pr->images()->sole(); + + expect($image->rules_version)->toBe(config('images.rules_version')) + ->and($image->copyright)->toBe('Musterfirma GmbH / Anna Beispiel') + ->and($image->credit_source?->value)->toBe('metadata') + ->and($image->title)->toBe('Werkshalle') + ->and($image->license_url)->toBe('https://example.com/lizenz') + ->and($image->focus_x)->toBe(100) + ->and($image->focus_y)->toBe(0) + ->and($image->license_type)->toBeNull() + ->and($image->people_rights_status)->toBeNull() + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + ->and($image->hasValidRightsConfirmation())->toBeTrue(); + + $confirmation = ImageRightsConfirmation::query()->sole(); + + expect($confirmation->channel)->toBe(ConfirmationChannel::Form) + ->and($confirmation->user_id)->toBe($owner->id) + ->and($confirmation->legal_text_key)->toBe('img-confirm-v2') + ->and($confirmation->text_snapshot)->toContain('/de/agb'); +}); + +test('an admin upload skips the confirmation, mails the customer and blocks nothing else', function () { + /** @var TestCase $this */ + Mail::fake(); + ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', 'Beispiel AG') + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->assertSee('Sie laden im Auftrag des Kunden hoch') + ->call('saveImage') + ->assertHasNoErrors() + ->assertSee('wartet auf Kundenbestätigung'); + + $image = $pr->images()->sole(); + + expect($image->customer_confirmation_requested_at)->not->toBeNull() + ->and($image->awaitsCustomerConfirmation())->toBeTrue() + ->and(ImageRightsConfirmation::query()->count())->toBe(0); + + Mail::assertQueued(PressReleaseImageConfirmationRequested::class, fn ($mail) => $mail->hasTo($owner->email) && $mail->image->is($image)); +}); + +test('the customer confirms an admin upload on the detail page with the magic link channel', function () { + /** @var TestCase $this */ + ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); + $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ + 'rights_confirmed_at' => null, + 'customer_confirmation_requested_at' => now(), + ]); + $this->actingAs($owner); + + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Titelbild bestätigen') + ->call('confirmImageRights') + ->assertHasErrors(['imageRightsConfirmed']) + ->set('imageRightsConfirmed', true) + ->call('confirmImageRights') + ->assertHasNoErrors() + ->assertDontSee('Titelbild bestätigen'); + + $confirmation = $image->rightsConfirmations()->sole(); + + expect($confirmation->channel)->toBe(ConfirmationChannel::MagicLink) + ->and($confirmation->user_id)->toBe($owner->id) + ->and($confirmation->file_sha256)->toBe($image->original_sha256) + ->and($image->fresh()->awaitsCustomerConfirmation())->toBeFalse() + ->and($image->fresh()->rights_confirmed_at)->not->toBeNull(); +}); + +test('removing a never published image deletes original, derivatives and confirmation for good', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $this->actingAs($owner); + + $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->set('newRightsConfirmed', true) + ->call('saveImage') + ->assertHasNoErrors(); + + $image = $pr->images()->sole(); + expect(Storage::disk('local')->allFiles())->not->toBe([]); + + $component->call('remove', $image->id)->assertDispatched('title-image-changed'); + + expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse() + ->and(ImageRightsConfirmation::query()->count())->toBe(0) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); + +test('removing a once published image keeps the original and the confirmation and only soft deletes', function () { + /** @var TestCase $this */ + ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('openUploadForm') + ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) + ->set('newCopyright', 'Beispiel AG') + ->set('newAiDeclaration', AiImageDeclaration::Original->value) + ->call('saveImage') + ->assertHasNoErrors(); + + $image = $pr->images()->sole(); + $image->forceFill(['published_ever_at' => now()])->save(); + + LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) + ->call('remove', $image->id); + + expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue() + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + ->and(Storage::disk('local')->exists($image->path))->toBeFalse(); +});