images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)

- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error
- CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link
- PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt
- ConversionSource::MailImageConfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:31:32 +00:00
parent 214aea8910
commit 3d3ddc2dc8
11 changed files with 1380 additions and 5 deletions

View file

@ -23,6 +23,9 @@ enum ConversionSource: string
case ProfileClaim = 'profile_claim'; case ProfileClaim = 'profile_claim';
/** „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, E10). */
case MailImageConfirmation = 'mail_image_confirmation';
case Organic = 'organic'; case Organic = 'organic';
public function label(): string public function label(): string
@ -32,6 +35,7 @@ enum ConversionSource: string
self::MailReject => 'Ablehnungsmail', self::MailReject => 'Ablehnungsmail',
self::MailReview => 'Prüfhinweis-Mail', self::MailReview => 'Prüfhinweis-Mail',
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite', self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
self::MailImageConfirmation => 'Mail „Bild bestätigen"',
self::Organic => 'ohne zugeordnete Herkunft', self::Organic => 'ohne zugeordnete Herkunft',
}; };
} }

View file

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
/**
* „Bild bestätigen" (Decision-Update E10): Ein Admin hat im Auftrag des
* Kunden ein Titelbild hochgeladen. Veröffentlicht wird es erst, wenn der
* Kontoinhaber die Rechtebestätigung selbst abgibt — der Magic-Link führt
* direkt auf die Meldung im Konto.
*/
class PressReleaseImageConfirmationRequested extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public readonly PressRelease $pressRelease,
public readonly PressReleaseImage $image,
public readonly string $confirmationUrl,
public readonly Carbon $expiresAt,
public readonly ?string $recipientName,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Bitte bestätigen: Titelbild für Ihre Pressemitteilung',
);
}
public function content(): Content
{
return new Content(view: 'emails.press-release.image-confirmation');
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Enums\ConfirmationChannel;
use App\Enums\ConversionSource;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Illuminate\Support\Facades\Mail;
/**
* Kundenbestätigung für Admin-Uploads (Decision-Update E10, Block B.4):
* Der Kontoinhaber der Meldung bekommt einen Magic-Link „Bild bestätigen",
* der ihn auf die Meldung im Konto führt. Dort gibt er dieselbe Bestätigung
* ab wie beim eigenen Upload; das Protokoll trägt den Kanal `magic_link`.
*/
class CustomerImageConfirmation
{
public const LINK_TTL_MINUTES = 60 * 24 * 3;
public function __construct(
private readonly MagicLinkGenerator $magicLinks,
private readonly PressReleaseImageUploader $uploader,
) {}
/**
* Link erzeugen und Mail an den Kontoinhaber schicken. Erneut aufrufbar;
* ein neuer Link entwertet den alten (MagicLinkGenerator).
*/
public function request(PressReleaseImage $image, ?string $requestedIp = null): bool
{
$pressRelease = $image->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,
);
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Services\PressRelease\Images;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
*
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
* und vollständig weg, die Zeile wird hart gelöscht;
* - veröffentlicht: Derivate weg, Zeile weich gelöscht; Original, Snapshot
* und Bestätigung bleiben für das Beweisarchiv (Block F übernimmt die
* Überführung und die Frist).
*
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
* wie bisher behandelt (Derivate weg, Soft Delete).
*/
class PressReleaseImageRemover
{
public function __construct(private readonly ImageService $imageService) {}
public function remove(PressReleaseImage $image): void
{
$this->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();
});
}
}

View file

@ -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 * Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen. * die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
@ -255,11 +283,11 @@ class PressReleaseImageUploader
return $current; 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); $variables = $this->textRenderer->variables($agbUrl);
ImageRightsConfirmation::query()->create([ return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(), 'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(), 'subject_id' => $image->getKey(),
'press_release_id' => $pressRelease->getKey(), 'press_release_id' => $pressRelease->getKey(),

View file

@ -0,0 +1,35 @@
<x-emails.layout title="Bitte bestätigen Sie das Titelbild" preheader="Für Ihre Pressemitteilung wurde ein Titelbild hinterlegt. Es wird erst veröffentlicht, wenn Sie die Bildrechte bestätigen.">
<p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
<p>
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.
</p>
<p class="title">{{ $pressRelease->title }}</p>
<dl class="meta">
<dt>Bildnachweis</dt>
<dd>{{ $image->copyright ?: '—' }}</dd>
<dt>KI-Kennzeichnung</dt>
<dd>{{ $image->ai_declaration?->label() ?? 'noch offen' }}</dd>
</dl>
<x-emails.button :url="$confirmationUrl">Bild ansehen und bestätigen</x-emails.button>
<p>
Der Link meldet Sie direkt in Ihrem Konto an und ist gültig bis
<strong>{{ $expiresAt->format('d.m.Y H:i') }} Uhr</strong>. Danach können Sie
sich jederzeit regulär anmelden und die Bestätigung auf der Seite der
Pressemitteilung nachholen.
</p>
<div class="note">
<p>
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.
</p>
</div>
</x-emails.layout>

View file

@ -691,7 +691,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</section> </section>
{{-- 5) MEDIEN --}} {{-- 5) MEDIEN --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" /> @if (config('images.rights_v2'))
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
@else
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@endif
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT {{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung Datei-Uploads erfordern eine vollständige Sicherheitsprüfung

View file

@ -0,0 +1,738 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Flux\Flux;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Titelbild-Manager, Formular v2 (Bildkennzeichnung Block B, Decision-Update
* 22.09.2026). Drei Blöcke: Bild, Angaben (Nachweis + KI-Kennzeichnung),
* Bestätigung. Herkunftsdaten der Datei belegen vor, sperren aber nichts
* (Phase 1). Der Uploader bestimmt alle effektiven Werte serverseitig; die
* hier gesperrten Properties sind nur der Merker für die Datei-Bindung (B.2).
*
* Admin-Upload: keine Bestätigung durch den Admin, das Bild wartet auf die
* Kundenbestätigung per Magic-Link (E10).
*/
new class extends Component {
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newImage = null;
public string $newCopyright = '';
public string $newAiDeclaration = '';
public string $newTitle = '';
public string $newDescription = '';
public string $newLicenseUrl = '';
public string $newSourceUrl = '';
public bool $newRightsConfirmed = false;
public bool $noCrop = false;
/** Fokuspunkt als "x,y" in Prozent (Neun-Punkte-Wahl, H.3). */
public string $focus = '50,50';
public bool $isUploadFormOpen = false;
public bool $showLegalDetails = false;
/** Zuletzt automatisch vorgeschlagener Nachweis (manuelle Eingaben bleiben). */
public string $autoCopyright = '';
#[Locked]
public ?string $provenanceStatus = null;
#[Locked]
public ?string $provenanceSha = null;
#[Locked]
public ?string $creditSuggestion = null;
#[Locked]
public ?string $aiSuggestion = null;
#[Locked]
public bool $enhancementHint = false;
public function mount(int $pressReleaseId): void
{
$this->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<string, string>
*/
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<string, string>
*/
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();
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
<flux:badge color="{{ $titleImage ? ($awaitsCustomerConfirmation ? 'amber' : 'green') : 'zinc' }}" size="sm">
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : __('gesetzt')) : __('Platzhalter aktiv') }}
</flux:badge>
</div>
@if ($titleImage)
<div class="mt-4 overflow-hidden rounded-md border border-zinc-200 dark:border-zinc-700">
<div class="relative aspect-[16/9] bg-zinc-50 dark:bg-zinc-800">
@php
$titleImageUrl =
$titleImage->variantUrl('cover') ??
($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url()));
@endphp
@if ($titleImageUrl)
<img src="{{ $titleImageUrl }}" alt="{{ $titleImage->title ?? __('Titelbild') }}"
class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
</div>
<div class="space-y-3 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $titleImage->title ?: __('Eigenes Titelbild') }}
</div>
@if ($titleImage->copyright)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
</p>
@elseif ($titleImage->author)
<p class="m-0 truncate text-xs text-zinc-500">© {{ $titleImage->author }}</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if ($aiBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null))
<flux:badge color="purple" size="xs">{{ $aiBadge }}</flux:badge>
@endif
@if ($titleImage->ai_declaration_source)
<span>{{ __('Kennzeichnung: :source', ['source' => $titleImage->ai_declaration_source->publicLabel()]) }}</span>
@endif
@if ($titleImage->width && $titleImage->height)
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
@endif
</div>
{{-- Bestätigungsstand (D.3): Kennung, AGB-Fassung, Zeitpunkt, Kanal. --}}
@if ($latestConfirmation)
<p class="m-0 text-xs text-zinc-500">
{{ __('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,
]) }}
</p>
@elseif ($titleImage->isLegacyRules())
<p class="m-0 text-xs text-zinc-400">{{ __('Bestand ohne Bestätigungsprotokoll.') }}</p>
@endif
</div>
@if ($canEdit)
<flux:button size="sm" variant="filled" icon="trash"
wire:click="remove({{ $titleImage->id }})"
wire:confirm="{{ __('Titelbild wirklich entfernen? Danach wird wieder der Platzhalter verwendet.') }}">
{{ __('Titelbild löschen') }}
</flux:button>
@endif
</div>
@if ($awaitsCustomerConfirmation)
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }}
@if ($isAdminActor)
<flux:button size="xs" variant="ghost" class="ml-2" wire:click="resendConfirmationRequest">
{{ __('Bestätigungslink erneut senden') }}
</flux:button>
@endif
</div>
@endif
</div>
</div>
@elseif($canEdit)
<div
class="mt-4 flex flex-col gap-4 rounded-md border border-dashed border-zinc-300 p-5 dark:border-zinc-700 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<flux:heading size="xs">{{ __('Hier fehlt ein Titelbild') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }}
</flux:text>
</div>
<flux:button type="button" variant="primary" icon="arrow-up-tray" wire:click="openUploadForm">
{{ __('Eigenes Titelbild hochladen') }}
</flux:button>
</div>
<flux:modal wire:model.self="isUploadFormOpen" :dismissible="false" class="w-full max-w-3xl">
@if ($isUploadFormOpen)
<form wire:submit="saveImage" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Titelbild hochladen') }}</flux:heading>
<flux:subheading>
{{ __('Bild auswählen, Bildnachweis und KI-Kennzeichnung angeben, bestätigen — fertig.') }}
</flux:subheading>
</div>
{{-- ===== 1 · Bild ===== --}}
<section class="space-y-3">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">1</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bild') }}</span>
</div>
@if (!$newImage)
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. :mb MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.', ['mb' => $maxMb])">
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
:text="__('JPG, PNG oder WebP · max. :mb MB', ['mb' => $maxMb])" with-progress />
</flux:file-upload>
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('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.') }}
</div>
@else
<div class="overflow-hidden rounded-md border border-zinc-200 dark:border-zinc-700">
<div class="relative aspect-[16/9] bg-zinc-50 dark:bg-zinc-800">
@if ($this->newImagePreviewUrl())
<img src="{{ $this->newImagePreviewUrl() }}"
alt="{{ __('Vorschau des gewählten Titelbilds') }}"
class="absolute inset-0 size-full {{ $noCrop ? 'object-contain' : 'object-cover' }}"
style="object-position: {{ str_replace(',', '% ', $focus) }}%" />
@else
<div class="absolute inset-0 flex items-center justify-center">
<flux:icon.photo class="size-10 text-zinc-400" />
</div>
@endif
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 px-3 py-2 dark:border-zinc-700">
<div class="min-w-0 text-xs text-zinc-500">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $newImage->getClientOriginalName() }}</span>
<span class="mx-1">·</span>
<span>{{ number_format($newImage->getSize() / 1048576, 2, ',', '.') }} MB</span>
</div>
<flux:button size="xs" variant="filled" icon="arrow-path" wire:click="removeNewImage">
{{ __('Anderes Bild wählen') }}
</flux:button>
</div>
</div>
@if ($provenanceState)
<div class="rounded-md border p-3 text-xs leading-relaxed
{{ $provenanceState['tone'] === 'amber' ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100' : ($provenanceState['tone'] === 'green' ? 'border-green-200 bg-green-50 text-green-900 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-100' : 'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300') }}"
data-provenance="{{ $provenanceState['key'] }}">
<span class="font-semibold">{{ $provenanceState['title'] }}</span>
{{ $provenanceState['text'] }}
@if ($provenanceState['key'] !== 'absent')
<span class="block mt-1 text-zinc-500">
{{ __('Stimmen die Herkunftsdaten nicht? Schreiben Sie uns an :email.', ['email' => config('legal.contact_email')]) }}
</span>
@endif
</div>
@endif
<div class="grid gap-3 sm:grid-cols-2">
<flux:select wire:model.live="focus" :label="__('Bildausschnitt: wichtiger Bereich')"
:description="__('Der Zuschnitt für Vorschauen hält diesen Bereich im Bild.')">
@foreach ($focusOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:switch wire:model.live="noCrop" align="right" :label="__('Nicht beschneiden')"
:description="__('Das ganze Bild bleibt sichtbar, mit neutralem Rand.')" />
</div>
@endif
<flux:error name="newImage" />
</section>
@if ($newImage)
{{-- ===== 2 · Angaben ===== --}}
<section class="space-y-4 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">2</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bildnachweis & KI-Kennzeichnung') }}</span>
</div>
<flux:input wire:model="newCopyright" :label="__('Bildnachweis')" :badge="__('Pflicht')"
:description="$creditSuggestion !== null
? __('Vorschlag aus den Metadaten der Datei — bitte prüfen. So erscheint der Nachweis unter dem Bild („Foto: …").')
: __('Vorschlag: Ihr Firmenname — bitte prüfen. Bei Stock- oder Agenturbildern den Nachweis so eintragen, wie die Lizenz ihn verlangt.')"
:placeholder="$companyName ?: __('z. B. Max Mustermann / Beispiel GmbH')" required />
<flux:radio.group wire:model="newAiDeclaration" :label="__('KI-Kennzeichnung')" :badge="__('Pflicht')"
:description="__('KI-generierte oder KI-bearbeitete Bilder erhalten öffentlich einen neutralen Hinweis. Übliche Bearbeitungen wie Zuschnitt, Farbkorrektur oder Schärfung gelten nicht als KI-Bearbeitung.')"
required>
@foreach ($aiOptions as $aiImageOption)
<flux:radio :value="$aiImageOption->value" :label="$aiImageOption->label()" />
@endforeach
</flux:radio.group>
<flux:error name="newAiDeclaration" />
<details class="group" @if ($newLicenseUrl || $newSourceUrl || $newTitle || $newDescription) open @endif>
<summary class="flex cursor-pointer list-none items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500 [&::-webkit-details-marker]:hidden">
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
{{ __('Optionale Angaben') }}
<span class="font-normal normal-case tracking-normal text-zinc-400">— {{ __('Titel, Bildunterschrift, Lizenz, Quelle') }}</span>
</summary>
<div class="mt-3 space-y-3">
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text')"
:description="__('Beschreibt das Bild für Suchmaschinen und Screenreader.')" />
<flux:input wire:model="newDescription" :label="__('Bildunterschrift')" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newLicenseUrl" type="url" :label="__('Lizenz (Link)')"
:description="__('Nur, wenn eine Lizenz die Nutzung regelt, z. B. Creative Commons oder Agentur.')" placeholder="https://…" />
<flux:input wire:model="newSourceUrl" type="url" :label="__('Quelle (Link)')"
:description="__('Fundstelle oder Ursprungsseite — keine Lizenz.')" placeholder="https://…" />
</div>
</div>
</details>
</section>
{{-- ===== 3 · Bestätigung ===== --}}
<section class="space-y-3 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">3</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
</div>
@if ($isAdminActor)
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('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.') }}
</div>
@else
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Ich bestätige')"
:description="$legalShort" />
<flux:error name="newRightsConfirmed" />
<button type="button" wire:click="$toggle('showLegalDetails')"
class="flex items-center gap-1 text-xs text-zinc-500 underline-offset-2 hover:underline"
aria-expanded="{{ $showLegalDetails ? 'true' : 'false' }}">
<flux:icon.chevron-right class="size-3.5 transition-transform {{ $showLegalDetails ? 'rotate-90' : '' }}" />
{{ __('Vollständiger Wortlaut') }}
@if ($legalTextKey)
<span class="text-zinc-400">({{ $legalTextKey }})</span>
@endif
</button>
@if ($showLegalDetails && $legalLong)
<blockquote class="m-0 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-xs leading-relaxed text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300" data-legal-text>
{{ $legalLong }}
</blockquote>
@endif
@endif
</section>
@endif
<div class="flex justify-end gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:button type="button" variant="filled" wire:click="closeUploadForm">
{{ __('Abbrechen') }}</flux:button>
@if ($newImage)
<flux:button type="submit" variant="primary" icon="arrow-up-tray">
{{ __('Hochladen & einsetzen') }}</flux:button>
@endif
</div>
</form>
@endif
</flux:modal>
@else
<div class="mt-4 rounded-md border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<flux:icon.photo class="mx-auto size-10 text-zinc-400" />
<flux:text class="mt-2 text-sm text-zinc-500">
{{ __('Noch kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }}</flux:text>
</div>
@endif
</flux:card>

View file

@ -700,7 +700,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
@endif @endif
{{-- 7) MEDIEN — Image-Manager direkt eingebunden --}} {{-- 7) MEDIEN — Image-Manager direkt eingebunden --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" /> @if (config('images.rights_v2'))
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
@else
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@endif
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT {{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung Datei-Uploads erfordern eine vollständige Sicherheitsprüfung

View file

@ -17,6 +17,9 @@ use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException; use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException; use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService; 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\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\QuotaExceededException;
@ -105,6 +108,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
*/ */
public bool $purchaseConfirmed = false; 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 public function mount(int $id): void
{ {
$this->id = $id; $this->id = $id;
@ -822,7 +867,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'categoryName' => $categoryName, 'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), '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(), 'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr) 'canEdit' => auth()->user()->can('update', $pr)
@ -1710,6 +1762,40 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif @endif
</article> </article>
{{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}}
@if ($awaitsImageConfirmation)
<article class="panel" data-image-confirmation>
<div class="panel-head">
<span class="section-eyebrow">{{ __('Titelbild bestätigen') }}</span>
</div>
<div class="p-5 space-y-4 max-w-[760px]">
<p class="m-0 text-[13.5px] leading-relaxed">
{{ __('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.') }}
</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[12.5px] m-0">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Bildnachweis') }}</dt>
<dd class="m-0">{{ $titleImage->copyright ?: '—' }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('KI-Kennzeichnung') }}</dt>
<dd class="m-0">{{ $titleImage->ai_declaration?->label() ?? '—' }}</dd>
</dl>
<flux:switch wire:model="imageRightsConfirmed" align="right" :label="__('Ich bestätige')" :description="$imageLegalShort" />
<flux:error name="imageRightsConfirmed" />
@if ($imageLegalLong)
<details class="group">
<summary class="flex cursor-pointer list-none items-center gap-1 text-[12px] text-[color:var(--color-ink-3)] [&::-webkit-details-marker]:hidden">
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
{{ __('Vollständiger Wortlaut') }}
</summary>
<blockquote class="m-0 mt-2 rounded-md border border-[color:var(--color-bg-rule)] p-3 text-[12px] leading-relaxed text-[color:var(--color-ink-3)]">{{ $imageLegalLong }}</blockquote>
</details>
@endif
<div>
<flux:button variant="primary" wire:click="confirmImageRights">{{ __('Bildrechte bestätigen') }}</flux:button>
</div>
</div>
</article>
@endif
{{-- ============== INHALT ============== --}} {{-- ============== INHALT ============== --}}
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">

View file

@ -0,0 +1,297 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ConfirmationChannel;
use App\Mail\PressReleaseImageConfirmationRequested;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Bildkennzeichnung Block B: Formular v2 im Titelbild-Manager, Admin-Upload
* mit Kundenbestätigung, Entfernen mit Sofortlöschung.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->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();
});