web/admin/pdf: gemeinsame Bildnachweis-Auflösung, Detailfenster am Artikelbild, Bestätigungsstand in Kunden- und Adminansicht, Wortwahl „automatisierte Prüfung" (Bildkennzeichnung Block D)

- App\Support\ImageCredit: Foto/Quelle-Zeile, KI-Hinweis mit Herkunft, Verantwortlich-Zeile nur mit Protokoll, sichere Links; PDF-Zeilen mit Bestätigungskennung
- Artikelseite: Overlay (eigenes i-Zeichen, Alpine ohne Plugin, Bottom-Sheet auf Mobil) mit Nachweis, Lizenz, Quelle, KI-Hinweis, Verantwortlich-Zeile, „Bild melden"
- Richtlinien 0.2: Prüfung als automatisiert beschrieben, Abschnitt „Bilder" (Decision-Update 3.5); Taglines, Suche, Newsrooms, Startseiten-CTA, Mails ohne „qualitätsgeprüft"
- Cache-Invalidierung bei Bildwechsel an veröffentlichten Meldungen; PHPStan-Baseline für das Resource bereinigt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:44:34 +00:00
parent 65ac72ce05
commit 2124ffd1f2
26 changed files with 578 additions and 199 deletions

View file

@ -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(),
);

View file

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

View file

@ -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,

View file

@ -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<string, mixed>|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<string, mixed>|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
{

View file

@ -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;
}

View file

@ -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;
}
/**

View file

@ -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;

159
app/Support/ImageCredit.php Normal file
View file

@ -0,0 +1,159 @@
<?php
namespace App\Support;
use App\Enums\AiDeclarationSource;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use Illuminate\Support\Str;
/**
* Eine Auflösung des Bildnachweises für alle Ausgaben (Bildkennzeichnung
* D.1): Artikelseite, Kunden- und Adminansicht, Beleg-PDF, Overlay.
*
* Regeln (Decision-Update 3.4):
* - Nachweiszeile: „Foto: [copyright]", sonst „Foto: [author]", sonst im
* Bestand „Quelle: [Firma]".
* - KI-Zusatz aus der Portalkennzeichnung, Bestand-Fallback `is_ai_generated`.
* - Verantwortlich-Zeile „Bild und Angaben: [Firma], eingereicht am [Datum]"
* NUR mit Bestätigungsprotokoll — der Altbestand hat keins.
* - Links nur mit http/https.
*/
final readonly class ImageCredit
{
private function __construct(
public ?string $credit,
public string $creditLabel,
public ?string $aiLabel,
public ?string $aiSourceLabel,
public ?string $licenseUrl,
public ?string $sourceUrl,
public ?string $responsibleLine,
public ?ImageRightsConfirmation $confirmation,
public bool $isLegacy,
) {}
public static function for(PressReleaseImage $image, ?Company $company = null): self
{
$company ??= $image->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<string>
*/
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;
}
}

View file

@ -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'],
];

View file

@ -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.',

View file

@ -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',

View file

@ -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',

View file

@ -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

View file

@ -108,7 +108,7 @@
<a href="{{ $presseechoUrl }}" style="font-weight: bold; text-decoration: none;"><span style="color: #1B2417;">presse</span><span style="color: #345636;">echo</span></a>
und
<a href="{{ $businessportalUrl }}" style="font-weight: bold; text-decoration: none;"><span style="color: #232A33;">businessportal</span><span style="color: #C84A1E;">24</span></a>
— ein Konto, qualitätsgeprüft, auf beiden Portalen veröffentlichen.
— ein Konto, auf beiden Portalen veröffentlichen.
</td>
</tr>
</table>

View file

@ -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',
]);

View file

@ -1,7 +1,7 @@
<x-emails.layout title="Ihre Pressemitteilung wurde veröffentlicht" preheader="Ihre Pressemitteilung wurde geprüft und veröffentlicht.">
<x-emails.layout title="Ihre Pressemitteilung wurde veröffentlicht" preheader="Ihre Pressemitteilung hat die automatisierte Prüfung bestanden und ist veröffentlicht.">
<p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
<p>Ihre Pressemitteilung wurde geprüft und erfolgreich veröffentlicht.</p>
<p>Ihre Pressemitteilung hat unsere automatisierte Prüfung bestanden und ist jetzt veröffentlicht.</p>
<p class="title">{{ $pressRelease->title }}</p>

View file

@ -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) --}}
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
@if ($aiImageBadge = $adminTitleImage->ai_declaration?->publicLabel() ?? ($adminTitleImage->is_ai_generated ? __('KI-generiert') : null))
<span class="badge hub">{{ $aiImageBadge }}</span>
@if ($adminCreditInfo->aiLabel)
<span class="badge hub">{{ $adminCreditInfo->aiLabel }}</span>
@if ($adminCreditInfo->aiSourceLabel)
<span class="text-[color:var(--color-ink-4)]">({{ $adminCreditInfo->aiSourceLabel }})</span>
@endif
@endif
@if ($adminTitleImage->copyright)
<span>{{ $adminTitleImage->copyright }}</span>
@if ($adminCreditInfo->credit)
<span>{{ $adminCreditInfo->creditLabel }}: {{ $adminCreditInfo->credit }}</span>
@endif
@if ($adminCreditInfo->confirmation)
<span class="text-[color:var(--color-ink-4)]">· {{ __('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]) }}</span>
@elseif ($adminTitleImage->awaitsCustomerConfirmation())
<span class="badge">{{ __('wartet auf Kundenbestätigung') }}</span>
@elseif ($adminCreditInfo->isLegacy)
<span class="text-[color:var(--color-ink-4)]">· {{ __('Bestand ohne Bestätigungsprotokoll') }}</span>
@endif
</div>
@endif

View file

@ -1748,15 +1748,20 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
<flux:icon.photo variant="micro" class="size-3.5" />
<span>{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
</div>
@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). --}}
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
@if ($aiImageBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null))
<span class="badge hub">{{ $aiImageBadge }}</span>
@if ($imageCreditInfo->aiLabel)
<span class="badge hub">{{ $imageCreditInfo->aiLabel }}</span>
@endif
@if ($titleImage->copyright)
<span>{{ $titleImage->copyright }}</span>
@if ($imageCreditInfo->credit)
<span>{{ $imageCreditInfo->creditLabel }}: {{ $imageCreditInfo->credit }}</span>
@endif
@if ($imageCreditInfo->confirmation)
<span class="text-[color:var(--color-ink-4)]">· {{ __('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]) }}</span>
@elseif ($titleImage->awaitsCustomerConfirmation())
<span class="badge">{{ __('wartet auf Ihre Bestätigung') }}</span>
@endif
</div>
@endif

View file

@ -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')

View file

@ -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 }}
</span>
@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. --}}
<div x-data="{ open: false, show() { this.open = true; this.$nextTick(() => this.$refs.close.focus()) }, hide() { this.open = false; this.$nextTick(() => this.$refs.trigger.focus()) } }"
@keydown.escape.window="if (open) hide()"
class="absolute bottom-3 left-3 z-10" data-image-credit>
<button type="button" x-ref="trigger" @click="open ? hide() : show()" :aria-expanded="open ? 'true' : 'false'"
aria-haspopup="dialog" aria-controls="image-credit-dialog"
class="flex size-7 items-center justify-center rounded-full bg-black/60 text-white ring-1 ring-white/50 backdrop-blur-[2px] hover:bg-black/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
title="{{ __('web.detail.image_credit_open') }}">
<span class="sr-only">{{ __('web.detail.image_credit_open') }}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9.5" stroke="currentColor" stroke-width="1.6" />
<path d="M12 10.5v6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<circle cx="12" cy="7.6" r="1.15" fill="currentColor" />
</svg>
</button>
<div x-show="open" x-cloak @click.outside="hide()" id="image-credit-dialog" role="dialog" aria-modal="true"
aria-labelledby="image-credit-title"
class="fixed inset-x-0 bottom-0 z-40 max-h-[80vh] overflow-y-auto border-t border-bg-rule-strong bg-bg p-5 text-[13px] text-ink shadow-2xl sm:absolute sm:inset-auto sm:bottom-10 sm:left-0 sm:w-[22rem] sm:border sm:shadow-lg">
<div class="flex items-start justify-between gap-3">
<h2 id="image-credit-title" class="m-0 text-[11px] font-semibold uppercase tracking-[0.14em] text-ink-3">{{ __('web.detail.image_credit_title') }}</h2>
<button type="button" x-ref="close" @click="hide()" class="text-[12px] text-ink-3 underline-offset-2 hover:text-ink hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ink">
{{ __('web.detail.image_credit_close') }}
</button>
</div>
<ul class="m-0 mt-3 list-none space-y-2 p-0 leading-[1.5]">
@if ($imageCreditInfo->credit)
<li>{{ $imageCreditInfo->creditLabel }}: <span class="font-medium">{{ $imageCreditInfo->credit }}</span></li>
@endif
@if ($imageCreditInfo->licenseUrl)
<li>{{ __('web.detail.image_license') }}: <a href="{{ $imageCreditInfo->licenseUrl }}" rel="nofollow noopener" target="_blank" class="underline underline-offset-2">{{ \Illuminate\Support\Str::limit($imageCreditInfo->licenseUrl, 60) }}</a></li>
@endif
@if ($imageCreditInfo->sourceUrl)
<li>{{ __('web.detail.image_source_link') }}: <a href="{{ $imageCreditInfo->sourceUrl }}" rel="nofollow noopener" target="_blank" class="underline underline-offset-2">{{ \Illuminate\Support\Str::limit($imageCreditInfo->sourceUrl, 60) }}</a></li>
@endif
@if ($imageCreditInfo->aiLabel)
<li>
{{ $image->ai_declaration?->value === 'ai_edited' ? __('web.detail.image_ai_hint_edited') : __('web.detail.image_ai_hint_generated') }}
@if ($imageCreditInfo->aiSourceLabel)
<span class="block text-ink-3">{{ __('web.detail.image_ai_source', ['source' => $imageCreditInfo->aiSourceLabel]) }}</span>
@endif
</li>
@endif
@if ($imageCreditInfo->responsibleLine)
<li class="text-ink-3">{{ $imageCreditInfo->responsibleLine }}</li>
@endif
</ul>
<a href="{{ $imageReportUrl }}" class="mt-4 inline-block text-[12px] text-ink-3 underline underline-offset-2 hover:text-ink">{{ __('web.detail.image_report') }}</a>
</div>
</div>
@endif
</div>
@if ($imageCaption || $imageCredit)
<div class="text-[11.5px] text-ink-3 mt-2.5 italic max-w-[720px]">
{{ $imageCaption }}@if ($imageCaption && $imageCredit) · @endif@if ($imageCredit)<span class="not-italic">{{ __('web.detail.photo') }}: {{ $imageCredit }}</span>@endif
@if ($imageCaption || $imageCreditInfo?->line())
<div class="text-[11.5px] text-ink-3 mt-2.5 italic max-w-[720px]" data-image-credit-line>
{{ $imageCaption }}@if ($imageCaption && $imageCreditInfo?->line()) · @endif@if ($imageCreditInfo?->line())<span class="not-italic">{{ $imageCreditInfo->line() }}</span>@endif
</div>
@endif
</section>

View file

@ -33,9 +33,11 @@
<p>
Diese Richtlinien gelten für alle Pressemitteilungen auf presseecho.de und
businessportal24.com. Sie sind Bestandteil unserer <a href="{{ route('agb') }}">AGB</a>.
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.
</p>
<h2>So funktioniert die Prüfung</h2>
@ -64,6 +66,19 @@
<li><strong>Passende Kategorie:</strong> Wählen Sie die Rubrik, die inhaltlich passt — nicht die mit der meisten Aufmerksamkeit.</li>
</ul>
<h2>Bilder</h2>
<p>
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
<a href="mailto:{{ $contactEmail }}">{{ $contactEmail }}</a>; wir prüfen das und
korrigieren Fehler. Bilder ohne geklärte Rechte entfernen wir; das
<a href="{{ route('melden') }}">Meldeverfahren</a> gilt auch für Bilder.
</p>
<h2>Ablehnungskategorien</h2>
</div>

View file

@ -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');

View file

@ -0,0 +1,141 @@
<?php
use App\Enums\ConfirmationChannel;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Support\ImageCredit;
use Database\Seeders\LegalDocumentSeeder;
use Tests\TestCase;
/**
* Bildkennzeichnung Block D: eine Auflösung für Nachweiszeile, Overlay,
* Kunden-/Adminansicht und PDF; Verantwortlich-Zeile nur mit Protokoll.
*/
function creditPublishedRelease(): array
{
$category = Category::factory()->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');
});

View file

@ -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');
});

View file

@ -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')

View file

@ -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')