diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index 4b8a1091..f3a121e5 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -21,6 +21,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Response; use Illuminate\Http\UploadedFile; +use Laravel\Sanctum\PersonalAccessToken; use RuntimeException; class PressReleaseImageController extends Controller @@ -148,9 +149,15 @@ class PressReleaseImageController extends Controller noCrop: (bool) ($validated['no_crop'] ?? false), ); + // Token-ID fürs Protokoll über den Bearer-Header auflösen: Sanctums + // currentAccessToken() ist im Test ein TransientToken ohne Datensatz; + // ohne Bearer bleibt die ID leer, der Kontoinhaber steht trotzdem drin. + $bearer = $request->bearerToken(); + $token = $bearer !== null ? PersonalAccessToken::findToken($bearer) : null; + $actor = ImageUploadActor::api( $request->user(), - $request->user()->currentAccessToken()?->getKey(), + $token !== null ? (int) $token->getKey() : null, $request->ip(), $request->userAgent(), ); diff --git a/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php index cd6079cd..420c6524 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php @@ -77,7 +77,7 @@ class StorePressReleaseImageRequest extends FormRequest if ($current === null || $given !== $current->key) { $validator->errors()->add( 'confirm_text_version', - sprintf('The confirmation text version is not current. Current version: %s.', $current?->key ?? 'none'), + sprintf('The confirmation text version is not current. Current version: %s.', $current !== null ? $current->key : 'none'), ); } }, diff --git a/app/Http/Resources/PressReleaseImageResource.php b/app/Http/Resources/PressReleaseImageResource.php index 7e2845ba..26c534bf 100644 --- a/app/Http/Resources/PressReleaseImageResource.php +++ b/app/Http/Resources/PressReleaseImageResource.php @@ -2,9 +2,13 @@ namespace App\Http\Resources; +use App\Models\PressReleaseImage; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +/** + * @mixin PressReleaseImage + */ class PressReleaseImageResource extends JsonResource { /** @@ -51,8 +55,8 @@ class PressReleaseImageResource extends JsonResource 'rights_confirmation' => $this->whenLoaded('latestRightsConfirmation', fn () => $this->latestRightsConfirmation === null ? null : [ 'version' => $this->latestRightsConfirmation->legal_text_key, 'agb_version' => $this->latestRightsConfirmation->agb_version, - 'confirmed_at' => $this->latestRightsConfirmation->confirmed_at?->toIso8601String(), - 'channel' => $this->latestRightsConfirmation->channel?->value, + 'confirmed_at' => $this->latestRightsConfirmation->confirmed_at->toIso8601String(), + 'channel' => $this->latestRightsConfirmation->channel->value, ]), 'warnings' => $this->warnings(), 'is_preview' => $this->is_preview, diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 499e29a7..26da76c7 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\URL; /** @@ -23,6 +24,55 @@ use Illuminate\Support\Facades\URL; * sagt, woher die Portalkennzeichnung stammt. `rules_version` NULL markiert * den Bestand vor der Umstellung; solche Bilder haben kein Beweisoriginal * und kein Bestätigungsprotokoll. + * + * @property int $id + * @property int $press_release_id + * @property string $disk + * @property string $path + * @property array|null $variants + * @property string|null $title + * @property string|null $description + * @property string|null $copyright + * @property string|null $author + * @property ImageLicenseType|null $license_type + * @property string|null $license_detail + * @property string|null $license_url + * @property string|null $source_url + * @property bool $persons_consent + * @property string|null $people_rights_status + * @property string|null $property_rights_status + * @property string|null $rights_notes + * @property Carbon|null $rights_confirmed_at + * @property bool $is_ai_generated + * @property AiImageDeclaration|null $ai_declaration + * @property AiImageDeclaration|null $ai_declaration_user + * @property AiDeclarationSource|null $ai_declaration_source + * @property bool $ai_declaration_locked + * @property CreditSource|null $credit_source + * @property ImageProvenanceStatus|null $provenance_status + * @property array|null $provenance + * @property Carbon|null $provenance_checked_at + * @property string|null $provenance_tool_version + * @property string|null $original_disk + * @property string|null $original_path + * @property string|null $original_sha256 + * @property string|null $published_snapshot_path + * @property string|null $published_snapshot_sha256 + * @property Carbon|null $published_ever_at + * @property Carbon|null $customer_confirmation_requested_at + * @property string|null $rules_version + * @property int|null $focus_x + * @property int|null $focus_y + * @property bool $no_crop + * @property bool $is_preview + * @property int $sort_order + * @property int|null $width + * @property int|null $height + * @property string|null $mime + * @property string|null $legacy_portal + * @property int|null $legacy_id + * @property-read PressRelease|null $pressRelease + * @property-read ImageRightsConfirmation|null $latestRightsConfirmation */ class PressReleaseImage extends Model { diff --git a/app/Services/PressRelease/Images/PressReleaseImageRemover.php b/app/Services/PressRelease/Images/PressReleaseImageRemover.php index e191c01c..47a08b27 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageRemover.php +++ b/app/Services/PressRelease/Images/PressReleaseImageRemover.php @@ -4,6 +4,7 @@ namespace App\Services\PressRelease\Images; use App\Models\PressReleaseImage; use App\Services\Image\ImageService; +use App\Support\WebCaches; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -29,6 +30,7 @@ class PressReleaseImageRemover if ($image->wasEverPublished() || $image->isLegacyRules()) { $image->delete(); + WebCaches::forgetPublicPages(); return; } diff --git a/app/Services/PressRelease/Images/PressReleaseImageUploader.php b/app/Services/PressRelease/Images/PressReleaseImageUploader.php index 74af312c..f4c0e1b7 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageUploader.php +++ b/app/Services/PressRelease/Images/PressReleaseImageUploader.php @@ -14,6 +14,7 @@ use App\Services\Image\ImageProvenanceInspector; use App\Services\Image\ImageRightsTextRenderer; use App\Services\Image\ImageService; use App\Services\Image\ProvenanceResult; +use App\Support\WebCaches; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -115,7 +116,7 @@ class PressReleaseImageUploader noCrop: $submission->noCrop, ); - return DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage { + $image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage { $locked = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressRelease->getKey()) @@ -169,6 +170,14 @@ class PressReleaseImageUploader return $image; }); + + // Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon + // öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar. + if ($pressRelease->published_at !== null) { + WebCaches::forgetPublicPages(); + } + + return $image; } catch (Throwable $exception) { // Kein Rollback für Dateien: Was geschrieben wurde, wird // ausdrücklich wieder entfernt (Feedback F09). @@ -202,12 +211,18 @@ class PressReleaseImageUploader 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 = DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation { $confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl); $image->forceFill(['rights_confirmed_at' => now()])->save(); return $confirmation; }); + + if ($image->pressRelease?->published_at !== null) { + WebCaches::forgetPublicPages(); + } + + return $confirmation; } /** diff --git a/app/Services/PressRelease/ProofPdfRenderer.php b/app/Services/PressRelease/ProofPdfRenderer.php index 9616ef87..ded601b4 100644 --- a/app/Services/PressRelease/ProofPdfRenderer.php +++ b/app/Services/PressRelease/ProofPdfRenderer.php @@ -2,13 +2,13 @@ namespace App\Services\PressRelease; -use App\Enums\AiImageDeclaration; use App\Enums\ImageLicenseType; use App\Enums\Portal; use App\Models\Company; use App\Models\Contact; use App\Models\PressRelease; use App\Models\PressReleaseImage; +use App\Support\ImageCredit; use GdImage; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; @@ -313,36 +313,21 @@ class ProofPdfRenderer */ private function imageCreditLines(PressReleaseImage $image): array { - $lines = []; + // Gemeinsame Auflösung (Bildkennzeichnung D.1/D.4): Nachweis, Lizenz, + // Quelle, KI-Kennzeichnung mit Herkunft, Bestätigung mit Kennung. + $lines = ImageCredit::for($image, $image->pressRelease?->company)->pdfLines(); - if (filled($image->copyright)) { - $lines[] = 'Bildnachweis: '.$image->copyright; - } + // Bestandsbelege: die Felder des alten Formulars bleiben lesbar. + if ($image->isLegacyRules()) { + if (filled($image->author) + && (blank($image->copyright) || ! str_contains((string) $image->copyright, (string) $image->author))) { + $lines[] = 'Urheber: '.$image->author; + } - if (filled($image->author) - && (blank($image->copyright) || ! str_contains((string) $image->copyright, (string) $image->author))) { - $lines[] = 'Urheber: '.$image->author; - } - - if ($image->license_type instanceof ImageLicenseType) { - $detail = filled($image->license_detail) ? ' ('.$image->license_detail.')' : ''; - $lines[] = 'Lizenz: '.$image->license_type->label().$detail; - } - - if (filled($image->license_url)) { - $lines[] = 'Lizenz-URL: '.$image->license_url; - } - - if (filled($image->source_url)) { - $lines[] = 'Quelle: '.$image->source_url; - } - - $aiLabel = $image->ai_declaration instanceof AiImageDeclaration - ? $image->ai_declaration->publicLabel() - : null; - - if ($aiLabel !== null || $image->is_ai_generated) { - $lines[] = 'KI-Kennzeichnung: '.($aiLabel ?? 'KI-generiert'); + if ($image->license_type instanceof ImageLicenseType) { + $detail = filled($image->license_detail) ? ' ('.$image->license_detail.')' : ''; + $lines[] = 'Lizenz: '.$image->license_type->label().$detail; + } } return $lines; diff --git a/app/Support/ImageCredit.php b/app/Support/ImageCredit.php new file mode 100644 index 00000000..fe387022 --- /dev/null +++ b/app/Support/ImageCredit.php @@ -0,0 +1,159 @@ +pressRelease?->company; + $companyName = $company?->name; + + $credit = self::clean($image->copyright) ?? self::clean($image->author); + $creditLabel = __('web.detail.photo'); + + if ($credit === null && $image->isLegacyRules() && filled($companyName)) { + $credit = $companyName; + $creditLabel = __('web.detail.source'); + } + + $aiLabel = $image->ai_declaration?->publicLabel() ?? ($image->is_ai_generated ? __('KI-generiert') : null); + + $confirmation = $image->relationLoaded('latestRightsConfirmation') + ? $image->latestRightsConfirmation + : $image->latestRightsConfirmation()->first(); + + $responsible = null; + + if ($confirmation !== null && filled($companyName)) { + $responsible = __('web.detail.image_responsible', [ + 'company' => $companyName, + 'date' => $confirmation->confirmed_at->locale('de')->isoFormat('D. MMMM YYYY'), + ]); + } + + return new self( + credit: $credit, + creditLabel: $creditLabel, + aiLabel: $aiLabel, + aiSourceLabel: $aiLabel !== null ? $image->ai_declaration_source?->publicLabel() ?? ($image->isLegacyRules() ? null : AiDeclarationSource::Customer->publicLabel()) : null, + licenseUrl: self::safeUrl($image->license_url), + sourceUrl: self::safeUrl($image->source_url), + responsibleLine: $responsible, + confirmation: $confirmation, + isLegacy: $image->isLegacyRules(), + ); + } + + /** + * Sichtbare Zeile unter dem Bild, z. B. „Foto: Musterfirma · KI-generiert". + */ + public function line(): ?string + { + if ($this->credit === null && $this->aiLabel === null) { + return null; + } + + $parts = []; + + if ($this->credit !== null) { + $parts[] = $this->creditLabel.': '.$this->credit; + } + + if ($this->aiLabel !== null) { + $parts[] = $this->aiLabel; + } + + return implode(' · ', $parts); + } + + public function hasDetails(): bool + { + return $this->credit !== null || $this->aiLabel !== null || $this->responsibleLine !== null; + } + + /** + * Zeilen für das Beleg-PDF (D.4). + * + * @return list + */ + public function pdfLines(): array + { + $lines = []; + + if ($this->credit !== null) { + $lines[] = 'Bildnachweis: '.$this->credit; + } + + if ($this->licenseUrl !== null) { + $lines[] = 'Lizenz: '.$this->licenseUrl; + } + + if ($this->sourceUrl !== null) { + $lines[] = 'Quelle: '.$this->sourceUrl; + } + + if ($this->aiLabel !== null) { + $lines[] = 'KI-Kennzeichnung: '.$this->aiLabel.($this->aiSourceLabel !== null ? ' ('.$this->aiSourceLabel.')' : ''); + } + + if ($this->confirmation !== null) { + $lines[] = sprintf( + 'Rechte bestätigt am %s (%s, AGB %s)', + $this->confirmation->confirmed_at->format('d.m.Y H:i'), + $this->confirmation->legal_text_key, + $this->confirmation->agb_version ?? '–', + ); + } + + return $lines; + } + + private static function clean(?string $value): ?string + { + $value = trim((string) $value); + + return $value !== '' ? $value : null; + } + + private static function safeUrl(?string $url): ?string + { + $url = trim((string) $url); + + if ($url === '' || ! Str::startsWith(strtolower($url), ['http://', 'https://'])) { + return null; + } + + return filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : null; + } +} diff --git a/database/seeders/LegalDocumentSeeder.php b/database/seeders/LegalDocumentSeeder.php index e1144c69..b42a42f9 100644 --- a/database/seeders/LegalDocumentSeeder.php +++ b/database/seeders/LegalDocumentSeeder.php @@ -19,6 +19,7 @@ class LegalDocumentSeeder extends Seeder [LegalDocument::TYPE_AGB, '0.6', '2026-07-03', 'B2B-Fassung (E2), anwaltsreif; externe Vorprüfung 11/11 bestätigt'], [LegalDocument::TYPE_DATENSCHUTZ, '0.3', '2026-07-03', 'B2B-angepasst; host-aware Bausteine Hub/Spokes'], [LegalDocument::TYPE_RICHTLINIEN, '0.1', '2026-07-03', 'Rahmentext; Kategorien aus rejection_categories'], + [LegalDocument::TYPE_RICHTLINIEN, '0.2', '2026-09-22', 'Prüfung als „automatisiert“ beschrieben, Abschnitt „Bilder“ (Bildkennzeichnung D.6, Decision-Update 3.5)'], [LegalDocument::TYPE_MELDEVERFAHREN, '0.1', '2026-07-03', 'Drei Wege: DSA / DSGVO / Depublikation'], [LegalDocument::TYPE_IMPRESSUM, '0.1', '2026-07-03', 'DDG-korrigiert, Verbund-Absatz, DSA-Kontaktpunkt'], ]; diff --git a/lang/de/static.php b/lang/de/static.php index ebddc239..6e78db42 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -530,8 +530,8 @@ return [ 'trust_eyebrow' => 'Was uns ausmacht', 'trust_heading' => 'Vier Prinzipien, die diese Plattform tragen', 'trust_1_k' => 'Qualität', - 'trust_1_t' => 'Geprüfte Mitteilungen', - 'trust_1_d' => 'Jede Mitteilung durchläuft eine Qualitätsprüfung, bevor sie online geht. Keine SEO-Spam-Texte, keine reinen Werbeanzeigen.', + 'trust_1_t' => 'Automatisiert geprüfte Mitteilungen', + 'trust_1_d' => 'Jede Mitteilung durchläuft vor der Veröffentlichung eine automatisierte Prüfung auf Spam, reine Werbung, Kategorie und formale Qualität. Ob Inhalte richtig sind, prüfen wir nicht — dafür steht der Absender.', 'trust_2_k' => 'Auffindbarkeit', 'trust_2_t' => 'Dauerhaft im Archiv', 'trust_2_d' => 'Pressemitteilungen bleiben im Archiv – auch nach Jahren. Mitteilungen aus mehr als einem Jahrzehnt sind weiterhin abrufbar.', diff --git a/lang/de/web.php b/lang/de/web.php index 134179ce..bb8f591b 100644 --- a/lang/de/web.php +++ b/lang/de/web.php @@ -137,7 +137,7 @@ return [ 'publish_band' => [ 'eyebrow' => 'Für Unternehmen', - 'title' => 'Ihre Pressemitteilung, qualitätsgeprüft veröffentlicht', + 'title' => 'Ihre Pressemitteilung, automatisiert geprüft und veröffentlicht', 'subline' => 'Anmelden, Meldung einreichen, veröffentlichen — mit Reichweite im DACH-Raum, eigenem Newsroom und dauerhaft auffindbarem Archiv.', 'cta_submit' => 'Pressemitteilung veröffentlichen', 'cta_pricing' => 'Tarife & Pakete', @@ -185,8 +185,8 @@ return [ 'imprint' => 'Impressum', 'privacy' => 'Datenschutz', 'tagline_short' => 'Pressemitteilungen · DACH', - 'tagline_long_presseecho' => 'Fachmeldungen aus Wirtschaft und Industrie. Qualitätsgeprüft. Strukturiert distribuiert.', - 'tagline_long_businessportal24' => 'Veröffentlichungs-Portal für qualitätsgeprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.', + 'tagline_long_presseecho' => 'Fachmeldungen aus Wirtschaft und Industrie. Automatisiert geprüft. Strukturiert distribuiert.', + 'tagline_long_businessportal24' => 'Veröffentlichungs-Portal für Pressemitteilungen aus Deutschland, Österreich und der Schweiz.', 'rights' => '© :year :brand · Alle Rechte vorbehalten', 'terms' => 'AGB', 'guidelines' => 'Richtlinien', @@ -228,9 +228,9 @@ return [ 'search' => [ 'meta_title' => 'Pressemitteilungen durchsuchen – :brand', - 'meta_description' => 'Durchsuchen Sie das geprüfte Pressemitteilungs-Archiv nach Stichwort, Unternehmen und Branche – qualitätsgeprüft und dauerhaft auffindbar im DACH-Raum.', + 'meta_description' => 'Durchsuchen Sie das Pressemitteilungs-Archiv nach Stichwort, Unternehmen und Branche – strukturiert und dauerhaft auffindbar im DACH-Raum.', 'eyebrow' => 'Archiv-Suche', - 'intro' => 'Stichwort, Unternehmen oder Branche – durchsuchen Sie das vollständige, qualitätsgeprüfte Archiv.', + 'intro' => 'Stichwort, Unternehmen oder Branche – durchsuchen Sie das vollständige Archiv.', 'placeholder' => 'Suchbegriff, Unternehmen oder Branche…', 'searching' => 'sucht…', 'reset' => 'Suche zurücksetzen', @@ -246,19 +246,19 @@ return [ 'previous_page' => 'Vorherige Seite', 'next_page' => 'Nächste Seite', 'discover_title' => 'Durchsuchen Sie das Archiv', - 'discover_text' => ':count geprüfte Pressemitteilungen — durchsuchbar nach Stichwort, Unternehmen und Branche. Geben Sie oben einen Suchbegriff ein oder wählen Sie eine Rubrik.', + 'discover_text' => ':count Pressemitteilungen — durchsuchbar nach Stichwort, Unternehmen und Branche. Geben Sie oben einen Suchbegriff ein oder wählen Sie eine Rubrik.', 'popular_categories' => 'Beliebte Rubriken', ], 'newsrooms_page' => [ 'meta_title' => 'Newsrooms – :brand', - 'meta_description' => 'Alle aktiven Unternehmens-Newsrooms im Überblick – qualitätsgeprüfte Pressemitteilungen je Unternehmen, gebündelt und dauerhaft auffindbar im DACH-Raum.', + 'meta_description' => 'Alle aktiven Unternehmens-Newsrooms im Überblick – Pressemitteilungen je Unternehmen, gebündelt und dauerhaft auffindbar im DACH-Raum.', 'eyebrow' => 'Unternehmens-Newsrooms', - 'intro' => 'Alle aktiven Unternehmen mit ihren qualitätsgeprüften Pressemitteilungen – gebündelt an einem Ort.', + 'intro' => 'Alle aktiven Unternehmen mit ihren Pressemitteilungen – gebündelt an einem Ort.', 'stat_total_sub' => 'mit veröffentlichten Meldungen', 'stat_active' => 'Aktiv (30 Tage)', 'stat_archive' => 'Meldungen im Archiv', - 'stat_archive_sub' => 'qualitätsgeprüft', + 'stat_archive_sub' => 'automatisiert geprüft', 'search_label' => 'Newsroom suchen', 'search_placeholder' => 'Unternehmen suchen…', 'reset_filter' => 'Filter zurücksetzen', @@ -269,11 +269,11 @@ return [ 'none_available' => 'Aktuell sind keine aktiven Newsrooms verfügbar.', 'cta_eyebrow' => 'Eigener Newsroom', 'cta_title' => 'Bündeln Sie Ihre Pressemitteilungen im eigenen Newsroom.', - 'cta_text' => 'Eigene Marken-URL · alle Meldungen zentral · SEO-Vorteil für Ihr Unternehmen · qualitätsgeprüft.', + 'cta_text' => 'Eigene Marken-URL · alle Meldungen zentral · SEO-Vorteil für Ihr Unternehmen.', ], 'newsroom_page' => [ - 'meta_description' => 'Newsroom von :company: alle qualitätsgeprüften Pressemitteilungen gebündelt an einem Ort.', + 'meta_description' => 'Newsroom von :company: alle Pressemitteilungen gebündelt an einem Ort.', 'back_aria' => 'Zurück zu allen Newsrooms', 'all_newsrooms' => 'Alle Newsrooms', 'recent_stat' => ':count Meldungen in den letzten 30 Tagen', @@ -295,7 +295,7 @@ return [ 'category' => [ 'fallback_name' => 'Branche', - 'default_description' => 'Aktuelle Pressemitteilungen aus dem Bereich :category – qualitätsgeprüft und strukturiert distribuiert im DACH-Raum.', + 'default_description' => 'Aktuelle Pressemitteilungen aus dem Bereich :category – strukturiert distribuiert im DACH-Raum.', 'meta_title' => 'Pressemitteilungen :category – :brand', 'eyebrow' => 'Branche', 'back_aria' => 'Zurück zu allen Meldungen', @@ -365,6 +365,17 @@ return [ 'recommended' => 'Empfehlung', 'recommended_hint' => 'Diese Pressemitteilung erreicht einen hohen Content-Score — basierend auf Quellenqualität, Verifizierungsstatus und inhaltlicher Relevanz.', 'photo' => 'Foto', + 'source' => 'Quelle', + 'image_credit_title' => 'Bildnachweis', + 'image_credit_open' => 'Bildnachweis anzeigen', + 'image_credit_close' => 'Schließen', + 'image_license' => 'Lizenz', + 'image_source_link' => 'Quelle', + 'image_ai_hint_generated' => 'Dieses Bild wurde mit KI erstellt.', + 'image_ai_hint_edited' => 'Dieses Bild wurde mit KI bearbeitet.', + 'image_ai_source' => 'Kennzeichnung: :source', + 'image_responsible' => 'Bild und Angaben: :company, eingereicht am :date', + 'image_report' => 'Bild melden', 'about_company' => 'Über das Unternehmen', 'press_contact' => 'Pressekontakt', 'verified_hint' => 'Verifizierter Publisher — Identität und Domain bestätigt', diff --git a/lang/en/web.php b/lang/en/web.php index 4b6da9a2..86ea4812 100644 --- a/lang/en/web.php +++ b/lang/en/web.php @@ -10,7 +10,7 @@ return [ 'meta' => [ 'home_title' => 'Latest press releases from business & industry – :brand', - 'home_description' => 'Press releases from Germany, Austria and Switzerland. Quality-checked. Distributed in a structured way.', + 'home_description' => 'Press releases from Germany, Austria and Switzerland. Automatically checked. Distributed in a structured way.', ], 'shared' => [ @@ -132,7 +132,7 @@ return [ 'publish_band' => [ 'eyebrow' => 'For companies', - 'title' => 'Your press release, quality-checked and published', + 'title' => 'Your press release, automatically checked and published', 'subline' => 'Sign up, submit your release, publish — with reach across the DACH region, your own newsroom and a permanently searchable archive.', 'cta_submit' => 'Publish a press release', 'cta_pricing' => 'Plans & pricing', @@ -180,8 +180,8 @@ return [ 'imprint' => 'Imprint', 'privacy' => 'Privacy', 'tagline_short' => 'Press releases · DACH', - 'tagline_long_presseecho' => 'Business and industry news. Quality-checked. Distributed in a structured way.', - 'tagline_long_businessportal24' => 'Publishing portal for quality-checked press releases from Germany, Austria and Switzerland.', + 'tagline_long_presseecho' => 'Business and industry news. Automatically checked. Distributed in a structured way.', + 'tagline_long_businessportal24' => 'Publishing portal for press releases from Germany, Austria and Switzerland.', 'rights' => '© :year :brand · All rights reserved', 'terms' => 'Terms', 'guidelines' => 'Guidelines', @@ -223,9 +223,9 @@ return [ 'search' => [ 'meta_title' => 'Search press releases – :brand', - 'meta_description' => 'Search the quality-checked press release archive by keyword, company and industry – permanently discoverable across the DACH region.', + 'meta_description' => 'Search the press release archive by keyword, company and industry – permanently discoverable across the DACH region.', 'eyebrow' => 'Archive search', - 'intro' => 'Keyword, company or industry – search the complete, quality-checked archive.', + 'intro' => 'Keyword, company or industry – search the complete archive.', 'placeholder' => 'Keyword, company or industry…', 'searching' => 'searching…', 'reset' => 'Reset search', @@ -247,13 +247,13 @@ return [ 'newsrooms_page' => [ 'meta_title' => 'Newsrooms – :brand', - 'meta_description' => 'All active company newsrooms at a glance – quality-checked press releases per company, bundled and permanently discoverable across the DACH region.', + 'meta_description' => 'All active company newsrooms at a glance – press releases per company, bundled and permanently discoverable across the DACH region.', 'eyebrow' => 'Company newsrooms', - 'intro' => 'All active companies with their quality-checked press releases – bundled in one place.', + 'intro' => 'All active companies with their press releases – bundled in one place.', 'stat_total_sub' => 'with published releases', 'stat_active' => 'Active (30 days)', 'stat_archive' => 'Releases in the archive', - 'stat_archive_sub' => 'quality-checked', + 'stat_archive_sub' => 'automatically checked', 'search_label' => 'Search newsrooms', 'search_placeholder' => 'Search companies…', 'reset_filter' => 'Reset filter', @@ -264,11 +264,11 @@ return [ 'none_available' => 'There are currently no active newsrooms.', 'cta_eyebrow' => 'Your own newsroom', 'cta_title' => 'Bundle your press releases in your own newsroom.', - 'cta_text' => 'Your own branded URL · all releases in one place · SEO benefit for your company · quality-checked.', + 'cta_text' => 'Your own branded URL · all releases in one place · SEO benefit for your company.', ], 'newsroom_page' => [ - 'meta_description' => ':company newsroom: all quality-checked press releases bundled in one place.', + 'meta_description' => ':company newsroom: all press releases bundled in one place.', 'back_aria' => 'Back to all newsrooms', 'all_newsrooms' => 'All newsrooms', 'recent_stat' => ':count releases in the last 30 days', @@ -290,7 +290,7 @@ return [ 'category' => [ 'fallback_name' => 'Industry', - 'default_description' => 'Latest press releases from the :category sector – quality-checked and distributed across the DACH region.', + 'default_description' => 'Latest press releases from the :category sector – and distributed across the DACH region.', 'meta_title' => ':category press releases – :brand', 'eyebrow' => 'Industry', 'back_aria' => 'Back to all releases', @@ -313,7 +313,7 @@ return [ 'related' => 'Related industries', 'cta_eyebrow' => 'For companies in :category', 'cta_title' => 'Publish your press release directly in :category.', - 'cta_text' => 'Reach via active newsrooms · Structured distribution across the DACH region · Quality-checked.', + 'cta_text' => 'Reach via active newsrooms · Structured distribution across the DACH region.', 'cta_submit' => 'Submit press release', 'cta_pricing' => 'View plans & industry packages', ], @@ -360,6 +360,17 @@ return [ 'recommended' => 'Recommended', 'recommended_hint' => 'This press release achieves a high content score — based on source quality, verification status and content relevance.', 'photo' => 'Photo', + 'source' => 'Source', + 'image_credit_title' => 'Image credit', + 'image_credit_open' => 'Show image credit', + 'image_credit_close' => 'Close', + 'image_license' => 'Licence', + 'image_source_link' => 'Source', + 'image_ai_hint_generated' => 'This image was created with AI.', + 'image_ai_hint_edited' => 'This image was edited with AI.', + 'image_ai_source' => 'Label: :source', + 'image_responsible' => 'Image and details: :company, submitted on :date', + 'image_report' => 'Report image', 'about_company' => 'About the company', 'press_contact' => 'Press contact', 'verified_hint' => 'Verified publisher — identity and domain confirmed', diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3c625901..91eae99d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -264,108 +264,6 @@ parameters: count: 1 path: app/Http/Resources/CompanyResource.php - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$copyright\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$created_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$description\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$disk\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$height\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$is_preview\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$mime\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$path\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$press_release_id\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$sort_order\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$title\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$updated_at\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$variants\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseImageResource\:\:\$width\.$#' - identifier: property.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Call to an undefined method App\\Http\\Resources\\PressReleaseImageResource\:\:url\(\)\.$#' - identifier: method.notFound - count: 2 - path: app/Http/Resources/PressReleaseImageResource.php - - - - message: '#^Call to an undefined method App\\Http\\Resources\\PressReleaseImageResource\:\:variantUrl\(\)\.$#' - identifier: method.notFound - count: 1 - path: app/Http/Resources/PressReleaseImageResource.php - - message: '#^Access to an undefined property App\\Http\\Resources\\PressReleaseResource\:\:\$backlink_url\.$#' identifier: property.notFound diff --git a/resources/views/components/emails/layout.blade.php b/resources/views/components/emails/layout.blade.php index 96a8a523..76bafe5e 100644 --- a/resources/views/components/emails/layout.blade.php +++ b/resources/views/components/emails/layout.blade.php @@ -108,7 +108,7 @@ presseecho und businessportal24 - — ein Konto, qualitätsgeprüft, auf beiden Portalen veröffentlichen. + — ein Konto, auf beiden Portalen veröffentlichen. diff --git a/resources/views/components/web/site-footer.blade.php b/resources/views/components/web/site-footer.blade.php index 08b3f5e0..37b459b9 100644 --- a/resources/views/components/web/site-footer.blade.php +++ b/resources/views/components/web/site-footer.blade.php @@ -8,7 +8,7 @@ 'name' => 'businessportal', 'accent' => '24', 'tagline_short' => 'Pressemitteilungen · DACH', - 'tagline_long' => 'Veröffentlichungs-Portal für qualitätsgeprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.', + 'tagline_long' => 'Veröffentlichungs-Portal für Pressemitteilungen aus Deutschland, Österreich und der Schweiz.', 'footer_legal' => '© :year businessportal24 · Alle Rechte vorbehalten', 'about_label' => 'Über businessportal24', ]); diff --git a/resources/views/emails/press-release/published.blade.php b/resources/views/emails/press-release/published.blade.php index 7a4dfc59..d3bbfd18 100644 --- a/resources/views/emails/press-release/published.blade.php +++ b/resources/views/emails/press-release/published.blade.php @@ -1,7 +1,7 @@ - +

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

-

Ihre Pressemitteilung wurde geprüft und erfolgreich veröffentlicht.

+

Ihre Pressemitteilung hat unsere automatisierte Prüfung bestanden und ist jetzt veröffentlicht.

{{ $pressRelease->title }}

diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index b5975513..a7118319 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -213,14 +213,25 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @php $adminTitleImage = $pr->images->sortByDesc('is_preview')->first(); @endphp - @if ($adminTitleImage && ($adminTitleImage->copyright || $adminTitleImage->is_ai_generated || $adminTitleImage->ai_declaration?->publicLabel())) - {{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act) --}} + @if ($adminTitleImage && ($adminCreditInfo = \App\Support\ImageCredit::for($adminTitleImage, $pr->company))->hasDetails()) + {{-- Bildnachweis, KI-Kennzeichnung, Herkunft und Bestätigungsstand + (Bildkennzeichnung D.1/D.3) --}}
- @if ($aiImageBadge = $adminTitleImage->ai_declaration?->publicLabel() ?? ($adminTitleImage->is_ai_generated ? __('KI-generiert') : null)) - {{ $aiImageBadge }} + @if ($adminCreditInfo->aiLabel) + {{ $adminCreditInfo->aiLabel }} + @if ($adminCreditInfo->aiSourceLabel) + ({{ $adminCreditInfo->aiSourceLabel }}) + @endif @endif - @if ($adminTitleImage->copyright) - {{ $adminTitleImage->copyright }} + @if ($adminCreditInfo->credit) + {{ $adminCreditInfo->creditLabel }}: {{ $adminCreditInfo->credit }} + @endif + @if ($adminCreditInfo->confirmation) + · {{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', ['date' => $adminCreditInfo->confirmation->confirmed_at->format('d.m.Y H:i'), 'key' => $adminCreditInfo->confirmation->legal_text_key, 'agb' => $adminCreditInfo->confirmation->agb_version ?? '–', 'channel' => $adminCreditInfo->confirmation->channel->value]) }} + @elseif ($adminTitleImage->awaitsCustomerConfirmation()) + {{ __('wartet auf Kundenbestätigung') }} + @elseif ($adminCreditInfo->isLegacy) + · {{ __('Bestand ohne Bestätigungsprotokoll') }} @endif
@endif diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 1da16a2c..d05a739b 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1748,15 +1748,20 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends {{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }} - @elseif ($titleImage && ($titleImage->copyright || $titleImage->is_ai_generated || $titleImage->ai_declaration?->publicLabel())) - {{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act): kurzes - Badge („KI-generiert" / „KI-bearbeitet"), kein Satz-Wortlaut. --}} + @elseif ($titleImage && ($imageCreditInfo = \App\Support\ImageCredit::for($titleImage, $pr->company))->hasDetails()) + {{-- Bildnachweis, KI-Kennzeichnung und Bestätigungsstand aus der + gemeinsamen Auflösung (Bildkennzeichnung D.1/D.3). --}}
- @if ($aiImageBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null)) - {{ $aiImageBadge }} + @if ($imageCreditInfo->aiLabel) + {{ $imageCreditInfo->aiLabel }} @endif - @if ($titleImage->copyright) - {{ $titleImage->copyright }} + @if ($imageCreditInfo->credit) + {{ $imageCreditInfo->creditLabel }}: {{ $imageCreditInfo->credit }} + @endif + @if ($imageCreditInfo->confirmation) + · {{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', ['date' => $imageCreditInfo->confirmation->confirmed_at->format('d.m.Y H:i'), 'key' => $imageCreditInfo->confirmation->legal_text_key, 'agb' => $imageCreditInfo->confirmation->agb_version ?? '–', 'channel' => $imageCreditInfo->confirmation->channel->value]) }} + @elseif ($titleImage->awaitsCustomerConfirmation()) + {{ __('wartet auf Ihre Bestätigung') }} @endif
@endif diff --git a/resources/views/web/presseecho.blade.php b/resources/views/web/presseecho.blade.php index ebbc9425..73410ff6 100644 --- a/resources/views/web/presseecho.blade.php +++ b/resources/views/web/presseecho.blade.php @@ -40,7 +40,7 @@ ? ($brand['meta_title'] ?? 'Aktuelle Pressemitteilungen aus Wirtschaft & Industrie – presseecho') : __('web.meta.home_title', ['brand' => ($brand['name'] ?? 'presseecho').($brand['accent'] ?? '')])) @section('meta_description', app()->getLocale() === 'de' - ? ($brand['meta_description'] ?? 'Qualitätsgeprüfte Pressemitteilungen aus Wirtschaft und Industrie im DACH-Raum.') + ? ($brand['meta_description'] ?? 'Pressemitteilungen aus Wirtschaft und Industrie im DACH-Raum — automatisiert geprüft, strukturiert distribuiert.') : __('web.meta.home_description')) @section('hreflang') diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index 9d906d1f..677926d7 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -3,6 +3,7 @@ @php use App\Enums\Portal; use App\Services\PressRelease\CanonicalAssigner; + use App\Support\ImageCredit; use App\Support\StructuredData; use Illuminate\Support\Str; @@ -47,10 +48,13 @@ $signMedia = $isPreview ?? false; $imageUrl = $image?->variantUrl('detail', $signMedia) ?? $image?->variantUrl('card', $signMedia) ?? $image?->url($signMedia); $imageCaption = $image?->description ?: $image?->title; - $imageCredit = $image?->copyright ?: $image?->author; - // KI-Bildkennzeichnung (Art. 50 KI-VO): Badge direkt am Bild - // („KI-generiert" / „KI-bearbeitet"), null bei Originalfotos. - $imageAiBadge = $image?->ai_declaration?->publicLabel(); + // Bildnachweis, KI-Kennzeichnung und Verantwortlich-Zeile aus einer + // Auflösung (Bildkennzeichnung D.1, Decision-Update 3.4). Die sichtbare + // Zeile unter dem Bild ist die Nennung, das Overlay die Erklärung. + $imageCreditInfo = $image ? ImageCredit::for($image, $company) : null; + $imageCredit = $imageCreditInfo?->credit; + $imageAiBadge = $imageCreditInfo?->aiLabel; + $imageReportUrl = route('melden', ['edition' => app()->getLocale()]); // Ohne Pressefoto: die im User-Panel gewählte Platzhalter-Variante. // Null bei Altbeständen ohne Variante — dann generischer Hero-Fallback. @@ -417,10 +421,65 @@ {{ $imageAiBadge }} @endif + + @if ($imageCreditInfo?->hasDetails()) + {{-- Detailfenster zum Bildnachweis (Bildkennzeichnung D.2, E-B13): + eigenes „i"-Zeichen, kein „cr"-Logo. Alpine ohne Plugin: Fokus + wandert in den Dialog und beim Schließen zurück; Escape schließt. + Auf Mobil ein Bottom-Sheet, ab sm ein Popover am Icon. --}} +
+ + +
+ @endif - @if ($imageCaption || $imageCredit) -
- {{ $imageCaption }}@if ($imageCaption && $imageCredit) · @endif@if ($imageCredit){{ __('web.detail.photo') }}: {{ $imageCredit }}@endif + @if ($imageCaption || $imageCreditInfo?->line()) +
+ {{ $imageCaption }}@if ($imageCaption && $imageCreditInfo?->line()) · @endif@if ($imageCreditInfo?->line()){{ $imageCreditInfo->line() }}@endif
@endif diff --git a/resources/views/web/richtlinien.blade.php b/resources/views/web/richtlinien.blade.php index 60b051d2..8128c86f 100644 --- a/resources/views/web/richtlinien.blade.php +++ b/resources/views/web/richtlinien.blade.php @@ -33,9 +33,11 @@

Diese Richtlinien gelten für alle Pressemitteilungen auf presseecho.de und businessportal24.com. Sie sind Bestandteil unserer AGB. - Unser Ziel ist einfach: Leser sollen sich darauf verlassen können, dass hier echte - Unternehmensnachrichten stehen — geprüft, den Richtlinien entsprechend und klar - gekennzeichnet. + Jede Mitteilung durchläuft vor der Veröffentlichung eine automatisierte Prüfung. Sie + sortiert Spam, reine Werbung und Beiträge aus, die keine Pressemitteilung sind, ordnet + die Kategorie zu und bewertet die formale Qualität des Textes. Eine Prüfung, ob Inhalte + richtig sind oder ob Sie die Rechte an Texten und Bildern besitzen, findet nicht statt. + Dafür sind Sie verantwortlich.

So funktioniert die Prüfung

@@ -64,6 +66,19 @@
  • Passende Kategorie: Wählen Sie die Rubrik, die inhaltlich passt — nicht die mit der meisten Aufmerksamkeit.
  • +

    Bilder

    +

    + Zu jedem Bild gehört ein Bildnachweis; er erscheint öffentlich unter dem Bild. Laden Sie + nur Bilder hoch, für die Sie die Rechte haben, und achten Sie darauf, dass abgebildete + Personen mit der Veröffentlichung einverstanden sind. Bilder, die mit KI erzeugt oder + bearbeitet wurden, kennzeichnen Sie beim Hochladen; enthält die Datei signierte + Herkunftsdaten dazu, prüfen wir sie und zeigen die Kennzeichnung am Bild. Halten Sie eine + von uns gesetzte Kennzeichnung für falsch, schreiben Sie uns an + {{ $contactEmail }}; wir prüfen das und + korrigieren Fehler. Bilder ohne geklärte Rechte entfernen wir; das + Meldeverfahren gilt auch für Bilder. +

    +

    Ablehnungskategorien

    diff --git a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php index d5d903d1..eaccb432 100644 --- a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php @@ -76,9 +76,13 @@ test('an outdated text version is rejected with the current key in the message', test('a valid upload stores the confirmation with the token and returns the new fields', function () { /** @var TestCase $this */ - ['pressRelease' => $pressRelease, 'user' => $user] = rightsV2ApiDraft(); + // Echter Bearer-Token statt Sanctum::actingAs, damit die Token-ID im + // Protokoll landet (TransientToken hat keinen Datensatz). + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); + $plainToken = $user->createToken('connektar', ['press-releases:read', 'press-release-images:write'])->plainTextToken; - $response = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + $response = $this->withToken($plainToken)->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => rightsV2ApiFixture('xmp-ai-generated.jpg'), 'ai_declaration' => 'ai_generated', 'copyright' => 'KI-Bild / Musterfirma', @@ -110,7 +114,7 @@ test('a valid upload stores the confirmation with the token and returns the new ->and($image->focus_y)->toBe(80) ->and($confirmation->channel)->toBe(ConfirmationChannel::Api) ->and($confirmation->user_id)->toBe($user->id) - ->and($confirmation->personal_access_token_id)->not->toBeNull() + ->and($confirmation->personal_access_token_id)->toBe($user->tokens()->sole()->getKey()) ->and($confirmation->user_agent)->toBe('ConnektarBot/2.0') ->and($response->json('data.urls.original'))->toBe(route('media.press-release-images.show', ['image' => $image->id])) ->and($response->json('data.path'))->not->toContain('press-release-originals'); diff --git a/tests/Feature/Images/ImageCreditOutputTest.php b/tests/Feature/Images/ImageCreditOutputTest.php new file mode 100644 index 00000000..90a70933 --- /dev/null +++ b/tests/Feature/Images/ImageCreditOutputTest.php @@ -0,0 +1,141 @@ +create(['portal' => Portal::Presseecho]); + $category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie']); + $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]); + + $release = PressRelease::factory() + ->published() + ->forPortal(Portal::Presseecho) + ->for($category) + ->for($company) + ->create(['slug' => 'bild-nachweis-test', 'published_at' => now()->subDay()]); + + return compact('category', 'company', 'release'); +} + +function creditConfirmationFor(PressReleaseImage $image, string $channel = 'form'): ImageRightsConfirmation +{ + return ImageRightsConfirmation::query()->create([ + 'subject_type' => $image->getMorphClass(), + 'subject_id' => $image->getKey(), + 'press_release_id' => $image->press_release_id, + 'company_id' => $image->pressRelease->company_id, + 'user_id' => $image->pressRelease->user_id, + 'channel' => ConfirmationChannel::from($channel), + 'legal_text_key' => 'img-confirm-v2', + 'text_snapshot' => 'Ich bestätige …', + 'agb_version' => '0.6', + 'file_sha256' => $image->original_sha256, + 'declaration_snapshot' => [], + 'confirmed_at' => now()->setDate(2026, 9, 22)->setTime(10, 0), + 'created_at' => now(), + ]); +} + +test('a formular v2 image renders credit line, ai hint with source and the responsible line', function () { + /** @var TestCase $this */ + ['release' => $release, 'company' => $company] = creditPublishedRelease(); + $image = PressReleaseImage::factory()->for($release)->rulesV2()->create([ + 'copyright' => 'Anna Beispiel / Beispiel AG', + 'ai_declaration' => 'ai_edited', + 'ai_declaration_user' => 'ai_edited', + 'ai_declaration_source' => 'customer', + 'license_url' => 'https://example.com/lizenz', + 'source_url' => 'javascript:alert(1)', + ]); + creditConfirmationFor($image); + + $credit = ImageCredit::for($image->fresh(), $company); + + expect($credit->line())->toBe('Foto: Anna Beispiel / Beispiel AG · KI-bearbeitet') + ->and($credit->responsibleLine)->toBe('Bild und Angaben: Beispiel AG, eingereicht am 22. September 2026') + ->and($credit->aiSourceLabel)->toBe('Angabe des Einreichers') + ->and($credit->licenseUrl)->toBe('https://example.com/lizenz') + ->and($credit->sourceUrl)->toBeNull() + ->and($credit->pdfLines())->toContain('Rechte bestätigt am 22.09.2026 10:00 (img-confirm-v2, AGB 0.6)'); + + $this->get('https://presseecho.test/de/press-release/bild-nachweis-test') + ->assertSuccessful() + ->assertSeeText('Foto: Anna Beispiel / Beispiel AG · KI-bearbeitet') + ->assertSee('data-image-credit', false) + ->assertSeeText('Dieses Bild wurde mit KI bearbeitet.') + ->assertSeeText('Kennzeichnung: Angabe des Einreichers') + ->assertSeeText('Bild und Angaben: Beispiel AG, eingereicht am 22. September 2026') + ->assertSeeText('Bild melden') + ->assertDontSee('javascript:alert', false); +}); + +test('a legacy image without credit falls back to the company as source and shows no responsible line', function () { + /** @var TestCase $this */ + ['release' => $release, 'company' => $company] = creditPublishedRelease(); + $image = PressReleaseImage::factory()->for($release)->legacy()->create(['is_ai_generated' => true]); + + $credit = ImageCredit::for($image, $company); + + expect($credit->line())->toBe('Quelle: Beispiel AG · KI-generiert') + ->and($credit->responsibleLine)->toBeNull() + ->and($credit->aiSourceLabel)->toBeNull() + ->and($credit->isLegacy)->toBeTrue(); + + $this->get('https://presseecho.test/de/press-release/bild-nachweis-test') + ->assertSuccessful() + ->assertSeeText('Quelle: Beispiel AG · KI-generiert') + ->assertDontSeeText('Bild und Angaben'); +}); + +test('a legacy image with author but without copyright uses the author and the old fields stay in the pdf lines', function () { + ['release' => $release, 'company' => $company] = creditPublishedRelease(); + $image = PressReleaseImage::factory()->for($release)->legacy()->create([ + 'author' => 'Max Mustermann', + 'license_type' => 'own', + ]); + + $credit = ImageCredit::for($image, $company); + + expect($credit->line())->toBe('Foto: Max Mustermann') + ->and($credit->pdfLines())->toBe(['Bildnachweis: Max Mustermann']); +}); + +test('an image without any detail renders no line and no overlay', function () { + /** @var TestCase $this */ + ['release' => $release] = creditPublishedRelease(); + PressReleaseImage::factory()->for($release)->legacy()->create(); + $release->company()->update(['name' => '']); + + $credit = ImageCredit::for($release->images()->first(), $release->company()->first()); + + expect($credit->line())->toBeNull() + ->and($credit->hasDetails())->toBeFalse(); +}); + +test('the guidelines page describes the automated check and the image rules under version 0.2', function () { + /** @var TestCase $this */ + $this->seed(LegalDocumentSeeder::class); + + expect(LegalDocument::current(LegalDocument::TYPE_RICHTLINIEN)?->version)->toBe('0.2'); + + $this->get('https://presseecho.test/de/richtlinien') + ->assertSuccessful() + ->assertSeeText('Jede Mitteilung durchläuft vor der Veröffentlichung eine automatisierte Prüfung.') + ->assertSeeText('Zu jedem Bild gehört ein Bildnachweis') + ->assertDontSeeText('geprüft, den Richtlinien entsprechend'); +}); diff --git a/tests/Feature/Legal/LegalDocumentTest.php b/tests/Feature/Legal/LegalDocumentTest.php index 78a0f87e..55f5395e 100644 --- a/tests/Feature/Legal/LegalDocumentTest.php +++ b/tests/Feature/Legal/LegalDocumentTest.php @@ -21,12 +21,13 @@ test('the agb version falls back to the config while the table is empty', functi expect(LegalDocument::currentAgbVersion())->toBe('9.9'); }); -test('the seeder registers all five legal documents idempotently', function () { +test('the seeder registers all legal documents idempotently', function () { /** @var TestCase $this */ $this->seed(LegalDocumentSeeder::class); $this->seed(LegalDocumentSeeder::class); // Wiederholung darf nichts duplizieren. - expect(LegalDocument::query()->count())->toBe(5); + expect(LegalDocument::query()->count())->toBe(6); + expect(LegalDocument::current(LegalDocument::TYPE_RICHTLINIEN)?->version)->toBe('0.2'); expect(LegalDocument::currentAgbVersion())->toBe('0.6'); expect(LegalDocument::current(LegalDocument::TYPE_DATENSCHUTZ)?->version)->toBe('0.3'); }); diff --git a/tests/Feature/Web/Businessportal24HomeTest.php b/tests/Feature/Web/Businessportal24HomeTest.php index b3d0cb22..225c90e3 100644 --- a/tests/Feature/Web/Businessportal24HomeTest.php +++ b/tests/Feature/Web/Businessportal24HomeTest.php @@ -42,7 +42,7 @@ test('businessportal24 homepage renders the editorial shell with real data', fun ->assertSeeText('Im Fokus') ->assertSeeText('Aktive Newsrooms') ->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung') - ->assertSeeText('Ihre Pressemitteilung, qualitätsgeprüft veröffentlicht') + ->assertSeeText('Ihre Pressemitteilung, automatisiert geprüft und veröffentlicht') ->assertSeeText('Konto anlegen') ->assertSeeText('Unser Qualitätsstandard') ->assertDontSeeText('redaktionell geprüft') diff --git a/tests/Feature/Web/PresseechoHomeTest.php b/tests/Feature/Web/PresseechoHomeTest.php index 3030bb96..01324742 100644 --- a/tests/Feature/Web/PresseechoHomeTest.php +++ b/tests/Feature/Web/PresseechoHomeTest.php @@ -41,7 +41,7 @@ test('presseecho homepage renders the editorial shell with real data', function ->assertSeeText('Im Fokus') ->assertSeeText('Aktive Newsrooms') ->assertSeeText('Veröffentlichen Sie Ihre Pressemitteilung') - ->assertSeeText('Ihre Pressemitteilung, qualitätsgeprüft veröffentlicht') + ->assertSeeText('Ihre Pressemitteilung, automatisiert geprüft und veröffentlicht') ->assertSeeText('Konto anlegen') ->assertSeeText('Unser Qualitätsstandard') ->assertDontSeeText('redaktionell geprüft')