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:
parent
24184f1dec
commit
9f44546082
26 changed files with 578 additions and 199 deletions
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
159
app/Support/ImageCredit.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue