images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet - legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations - image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads - ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error - PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung - ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
83cb6e14cd
commit
214aea8910
36 changed files with 2631 additions and 30 deletions
84
app/Console/Commands/BackfillImageRightsConfirmations.php
Normal file
84
app/Console/Commands/BackfillImageRightsConfirmations.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalText;
|
||||
use App\Models\PressReleaseImage;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung A.3: Die Bestätigungen des alten Formulars
|
||||
* (`rights_confirmed_at`, ohne Kennung) bekommen rückwirkend je eine Zeile
|
||||
* im Protokoll mit Verweis auf `img-confirm-v1`. Ohne IP und User-Agent —
|
||||
* die wurden damals nicht erfasst. Idempotent.
|
||||
*/
|
||||
class BackfillImageRightsConfirmations extends Command
|
||||
{
|
||||
protected $signature = 'image-rights:backfill-confirmations {--dry-run : Nur zählen, nichts schreiben}';
|
||||
|
||||
protected $description = 'Legt für Bilder des alten Formulars rückwirkend einen Protokolleintrag mit img-confirm-v1 an';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$text = LegalText::findByKey('img-confirm-v1');
|
||||
|
||||
if ($text === null) {
|
||||
$this->error('img-confirm-v1 fehlt — zuerst LegalTextSeeder ausführen.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query = PressReleaseImage::query()
|
||||
->withTrashed()
|
||||
->whereNotNull('rights_confirmed_at')
|
||||
->whereDoesntHave('rightsConfirmations')
|
||||
->with('pressRelease');
|
||||
|
||||
$count = $query->count();
|
||||
$this->info("Bilder mit alter Bestätigung ohne Protokoll: {$count}");
|
||||
|
||||
if ($this->option('dry-run') || $count === 0) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$written = 0;
|
||||
|
||||
$query->chunkById(200, function ($images) use ($text, &$written): void {
|
||||
foreach ($images as $image) {
|
||||
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::Form,
|
||||
'legal_text_id' => $text->id,
|
||||
'legal_text_key' => $text->key,
|
||||
'text_snapshot' => $text->body,
|
||||
'agb_version' => $text->bound_agb_version,
|
||||
'locale' => $text->locale,
|
||||
'file_sha256' => null,
|
||||
'declaration_snapshot' => [
|
||||
'copyright' => $image->copyright,
|
||||
'author' => $image->author,
|
||||
'license_type' => $image->license_type?->value,
|
||||
'license_detail' => $image->license_detail,
|
||||
'license_url' => $image->license_url,
|
||||
'source_url' => $image->source_url,
|
||||
'ai_declaration' => $image->ai_declaration?->value,
|
||||
'backfilled' => true,
|
||||
],
|
||||
'confirmed_at' => $image->rights_confirmed_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$written++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Protokolleinträge angelegt: {$written}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
27
app/Enums/AiDeclarationSource.php
Normal file
27
app/Enums/AiDeclarationSource.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Woher die effektive KI-Kennzeichnung eines Bildes stammt (Decision-Update
|
||||
* S6): Angabe des Kunden, Vorbelegung aus ungesicherten Metadaten, geprüfte
|
||||
* Content Credentials oder eine Admin-Korrektur. Wird öffentlich im
|
||||
* Detailfenster genannt („Angabe des Einreichers" usw.).
|
||||
*/
|
||||
enum AiDeclarationSource: string
|
||||
{
|
||||
case Customer = 'customer';
|
||||
case IptcPrefill = 'iptc_prefill';
|
||||
case C2paVerified = 'c2pa_verified';
|
||||
case AdminCorrection = 'admin_correction';
|
||||
|
||||
public function publicLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Customer => __('Angabe des Einreichers'),
|
||||
self::IptcPrefill => __('aus Metadaten der Datei vorbelegt, vom Einreicher bestätigt'),
|
||||
self::C2paVerified => __('aus geprüften Herkunftsdaten der Datei'),
|
||||
self::AdminCorrection => __('vom Portal nach Prüfung korrigiert'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ namespace App\Enums;
|
|||
* Pflichtauswahl beim Bild-Upload (Decision-Update KI-Kennzeichnung,
|
||||
* 08.07.2026). Bewusste Über-Erfüllung: gelabelt wird jedes deklarierte
|
||||
* KI-Bild — auch stilisiert/abstrakt, nicht nur fotorealistische Deepfakes.
|
||||
*
|
||||
* Seit Formular v2 (Bildkennzeichnung, 22.09.2026) heißt die erste Option
|
||||
* „Ohne generative KI erstellt oder bearbeitet": Sie gilt auch für Logos,
|
||||
* Grafiken und Screenshots, nicht nur für Fotos (Feedback F11). Der
|
||||
* gespeicherte Wert `original` bleibt aus Kompatibilitätsgründen.
|
||||
*/
|
||||
enum AiImageDeclaration: string
|
||||
{
|
||||
|
|
@ -21,9 +26,9 @@ enum AiImageDeclaration: string
|
|||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Original => __('Originalfoto'),
|
||||
self::AiGenerated => __('KI-generiertes Bild'),
|
||||
self::AiEdited => __('KI-bearbeitetes Bild'),
|
||||
self::Original => __('Ohne generative KI erstellt oder bearbeitet'),
|
||||
self::AiGenerated => __('KI-generiert'),
|
||||
self::AiEdited => __('KI-bearbeitet (z. B. generative Füllung, KI-Retusche)'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +46,40 @@ enum AiImageDeclaration: string
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rangfolge für die Portalregel „Kennzeichnung = max(geprüfter Befund,
|
||||
* Kundenangabe)" (Bildkennzeichnung A.4). Das ist eine bewusste
|
||||
* Portalregel und keine Aussage über den Wahrheitsgehalt: Ein
|
||||
* KI-erzeugter Bestandteil macht ein Bild nicht automatisch vollständig
|
||||
* KI-generiert (Feedback F11) — der Befund wird vorher fachlich
|
||||
* zugeordnet, erst dann greift die Mindestkennzeichnung.
|
||||
*/
|
||||
public function rank(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Original => 0,
|
||||
self::AiEdited => 1,
|
||||
self::AiGenerated => 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die strengere von zwei Kennzeichnungen nach rank(); null zählt als
|
||||
* „keine Angabe".
|
||||
*/
|
||||
public static function strictest(?self $a, ?self $b): ?self
|
||||
{
|
||||
if ($a === null) {
|
||||
return $b;
|
||||
}
|
||||
|
||||
if ($b === null) {
|
||||
return $a;
|
||||
}
|
||||
|
||||
return $b->rank() > $a->rank() ? $b : $a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionsliste für Radios/Selects: value => label.
|
||||
*
|
||||
|
|
|
|||
15
app/Enums/ConfirmationChannel.php
Normal file
15
app/Enums/ConfirmationChannel.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Über welchen Weg eine Bildrechte-Bestätigung abgegeben wurde
|
||||
* (Bildkennzeichnung A.2). `admin` ist kein Bestätigungskanal: Ein
|
||||
* Admin-Upload wartet auf die Kundenbestätigung per Magic-Link (E10).
|
||||
*/
|
||||
enum ConfirmationChannel: string
|
||||
{
|
||||
case Form = 'form';
|
||||
case Api = 'api';
|
||||
case MagicLink = 'magic_link';
|
||||
}
|
||||
14
app/Enums/CreditSource.php
Normal file
14
app/Enums/CreditSource.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Woher der Vorschlag für den Bildnachweis kam, den der Einreicher
|
||||
* übernommen oder geändert hat (Bildkennzeichnung A.1).
|
||||
*/
|
||||
enum CreditSource: string
|
||||
{
|
||||
case Customer = 'customer';
|
||||
case Metadata = 'metadata';
|
||||
case Api = 'api';
|
||||
}
|
||||
51
app/Enums/ImageProvenanceStatus.php
Normal file
51
app/Enums/ImageProvenanceStatus.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Ergebnisklasse der Herkunftsprüfung eines Bild-Uploads (Bildkennzeichnung
|
||||
* A.4, Feedback F02). Phase 1 liest nur ungesicherte IPTC/XMP/EXIF-Angaben,
|
||||
* kann also höchstens `unsigned_metadata` liefern. Die `validated_*`-Stufen
|
||||
* entstehen erst mit der C2PA-Signaturprüfung (Phase 2, Block I).
|
||||
*/
|
||||
enum ImageProvenanceStatus: string
|
||||
{
|
||||
/** Keine unterstützten Herkunftsdaten in der Datei. */
|
||||
case Absent = 'absent';
|
||||
|
||||
/** IPTC/XMP/EXIF gefunden — frei editierbar, nicht signiert. */
|
||||
case UnsignedMetadata = 'unsigned_metadata';
|
||||
|
||||
/** C2PA-Manifest, Signatur und Dateibindung geprüft, Aussteller in der Vertrauensliste. */
|
||||
case ValidatedTrusted = 'validated_trusted';
|
||||
|
||||
/** C2PA technisch geprüft, Aussteller nicht in der Vertrauensliste. */
|
||||
case ValidatedUntrusted = 'validated_untrusted';
|
||||
|
||||
/** C2PA vorhanden, Prüfung fehlgeschlagen (Signatur oder Bindung). */
|
||||
case Invalid = 'invalid';
|
||||
|
||||
/** Format oder Manifestversion nicht unterstützt. */
|
||||
case Unsupported = 'unsupported';
|
||||
|
||||
/** Technischer Fehler oder Zeitüberschreitung — Prüfung nicht abgeschlossen. */
|
||||
case Error = 'error';
|
||||
|
||||
/**
|
||||
* Nur ein vertrauenswürdig geprüfter Befund darf eine Kennzeichnung
|
||||
* erzwingen (Decision-Update E9).
|
||||
*/
|
||||
public function allowsLock(): bool
|
||||
{
|
||||
return $this === self::ValidatedTrusted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob überhaupt Herkunftsangaben vorliegen, aus denen das Formular
|
||||
* vorbelegt werden kann.
|
||||
*/
|
||||
public function hasMetadata(): bool
|
||||
{
|
||||
return in_array($this, [self::UnsignedMetadata, self::ValidatedTrusted, self::ValidatedUntrusted], true);
|
||||
}
|
||||
}
|
||||
128
app/Models/ImageRightsConfirmation.php
Normal file
128
app/Models/ImageRightsConfirmation.php
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Protokollierte Rechtebestätigung zu einem Bild (Bildkennzeichnung A.2).
|
||||
*
|
||||
* Insert-only: `ImageRightsConfirmationObserver` wirft bei Update und
|
||||
* Delete. Die einzige zulässige Löschung ist die Sofortlöschung eines nie
|
||||
* veröffentlichten Uploads (Decision-Update E4) über purgeForUnpublished().
|
||||
*
|
||||
* `user_id` ist der Kontoinhaber und damit der Vertragspartner (E1);
|
||||
* `company_id` ist nur die Zuordnung zum Firmenprofil. `text_snapshot` hält
|
||||
* den gerenderten Wortlaut mit aufgelöster AGB-Fassung fest, damit die
|
||||
* Bestätigung auch dann rekonstruierbar bleibt, wenn `legal_texts` und
|
||||
* `legal_documents` weiterwachsen.
|
||||
*/
|
||||
class ImageRightsConfirmation extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/**
|
||||
* Vom Observer geprüfter Freischein für die Sofortlöschung nie
|
||||
* veröffentlichter Uploads (E.3). Nie von außen setzen.
|
||||
*/
|
||||
public bool $purgingUnpublished = false;
|
||||
|
||||
protected $fillable = [
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'press_release_id',
|
||||
'company_id',
|
||||
'user_id',
|
||||
'personal_access_token_id',
|
||||
'channel',
|
||||
'legal_text_id',
|
||||
'legal_text_key',
|
||||
'text_snapshot',
|
||||
'agb_version',
|
||||
'locale',
|
||||
'file_sha256',
|
||||
'declaration_snapshot',
|
||||
'ip_hmac',
|
||||
'user_agent',
|
||||
'confirmed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'channel' => ConfirmationChannel::class,
|
||||
'declaration_snapshot' => 'array',
|
||||
'confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pseudonymisierte IP-Ableitung: HMAC mit eigenem Schlüssel und
|
||||
* Versionspräfix (Decision-Update 3.7). Kein reiner Hash — ohne den
|
||||
* Schlüssel lässt sich keine Adresse zuordnen.
|
||||
*/
|
||||
public static function hashIp(?string $ip): ?string
|
||||
{
|
||||
if (blank($ip)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = (string) config('images.confirmation_ip_key');
|
||||
$version = (string) config('images.confirmation_ip_hash_version', 'v1');
|
||||
|
||||
return $version.':'.hash_hmac('sha256', $ip, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sofortlöschung eines nie veröffentlichten Uploads (E.3). Nur hier
|
||||
* darf eine Bestätigung verschwinden — der Observer prüft das Flag.
|
||||
*/
|
||||
public function purgeForUnpublished(): void
|
||||
{
|
||||
$this->purgingUnpublished = true;
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphTo<Model, $this>
|
||||
*/
|
||||
public function subject(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<PressRelease, $this>
|
||||
*/
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Company, $this>
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<LegalText, $this>
|
||||
*/
|
||||
public function legalText(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LegalText::class);
|
||||
}
|
||||
}
|
||||
113
app/Models/LegalText.php
Normal file
113
app/Models/LegalText.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Unveränderliche Fassung eines Bestätigungstextes (Bildkennzeichnung A.3).
|
||||
*
|
||||
* Zeilen werden nie geändert oder gelöscht. Eine Kennung (`key`) gehört zu
|
||||
* genau einem Wortlaut; ein neuer Wortlaut bekommt eine neue Kennung. Der
|
||||
* Text ist an die AGB-Fassung gebunden, deren Paragrafen er nennt
|
||||
* (Decision-Update 3.3): Solange AGB 0.6 gilt, ist `img-confirm-v2` aktiv,
|
||||
* mit AGB 0.7 wird `img-confirm-v3` wirksam.
|
||||
*/
|
||||
class LegalText extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
public const TYPE_IMAGE_RIGHTS_CONFIRMATION = 'image_rights_confirmation';
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'type',
|
||||
'locale',
|
||||
'title',
|
||||
'body',
|
||||
'short_body',
|
||||
'bound_agb_version',
|
||||
'body_sha256',
|
||||
'effective_from',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'effective_from' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $text): void {
|
||||
$text->body_sha256 = hash('sha256', $text->body);
|
||||
});
|
||||
|
||||
static::updating(fn () => throw new \LogicException('legal_texts ist unveränderlich — neue Fassung als neue Zeile anlegen.'));
|
||||
static::deleting(fn () => throw new \LogicException('legal_texts ist unveränderlich — Fassungen werden nie gelöscht.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die heute gültige Fassung eines Typs für die gegebene AGB-Fassung.
|
||||
* Fällt auf die jüngste gültige Fassung ohne AGB-Bindung zurück, damit
|
||||
* ein Text ohne Paragrafenverweis für jede AGB-Fassung taugt.
|
||||
*/
|
||||
public static function currentFor(string $type, ?string $agbVersion, string $locale = 'de'): ?self
|
||||
{
|
||||
$query = static::query()
|
||||
->where('type', $type)
|
||||
->where('locale', $locale)
|
||||
->whereNotNull('effective_from')
|
||||
->where('effective_from', '<=', today())
|
||||
->orderByDesc('effective_from')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($agbVersion !== null) {
|
||||
$bound = (clone $query)->where('bound_agb_version', $agbVersion)->first();
|
||||
|
||||
if ($bound !== null) {
|
||||
return $bound;
|
||||
}
|
||||
}
|
||||
|
||||
return (clone $query)->whereNull('bound_agb_version')->first();
|
||||
}
|
||||
|
||||
public static function findByKey(string $key): ?self
|
||||
{
|
||||
return static::query()->where('key', $key)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob eine Kennung heute abgegeben werden darf (aktive Fassung des Typs).
|
||||
*/
|
||||
public function isCurrentFor(?string $agbVersion, string $locale = 'de'): bool
|
||||
{
|
||||
return static::currentFor($this->type, $agbVersion, $locale)?->is($this) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter im Wortlaut auflösen. Der gerenderte Text wird mit der
|
||||
* Bestätigung gespeichert, der Rohtext bleibt hier unverändert.
|
||||
*
|
||||
* @param array<string, string> $variables
|
||||
*/
|
||||
public function render(array $variables, bool $short = false): string
|
||||
{
|
||||
$template = $short ? ($this->short_body ?? $this->body) : $this->body;
|
||||
|
||||
return strtr($template, collect($variables)
|
||||
->mapWithKeys(fn (string $value, string $name): array => ['{'.$name.'}' => $value])
|
||||
->all());
|
||||
}
|
||||
|
||||
public function effectiveFromFormatted(): ?string
|
||||
{
|
||||
return $this->effective_from
|
||||
? Carbon::parse($this->effective_from)->locale('de')->isoFormat('D. MMMM YYYY')
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,15 +2,33 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\CreditSource;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\ImageProvenanceStatus;
|
||||
use Database\Factories\PressReleaseImageFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
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\Facades\URL;
|
||||
|
||||
/**
|
||||
* Bild einer Pressemitteilung. Seit Formular v2 (Bildkennzeichnung,
|
||||
* 22.09.2026) gilt: `ai_declaration` ist die öffentliche Portalkennzeichnung,
|
||||
* `ai_declaration_user` die Angabe des Einreichers, `ai_declaration_source`
|
||||
* sagt, woher die Portalkennzeichnung stammt. `rules_version` NULL markiert
|
||||
* den Bestand vor der Umstellung; solche Bilder haben kein Beweisoriginal
|
||||
* und kein Bestätigungsprotokoll.
|
||||
*/
|
||||
class PressReleaseImage extends Model
|
||||
{
|
||||
/** @use HasFactory<PressReleaseImageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -33,6 +51,25 @@ class PressReleaseImage extends Model
|
|||
'rights_confirmed_at',
|
||||
'is_ai_generated',
|
||||
'ai_declaration',
|
||||
'ai_declaration_user',
|
||||
'ai_declaration_source',
|
||||
'ai_declaration_locked',
|
||||
'credit_source',
|
||||
'provenance_status',
|
||||
'provenance',
|
||||
'provenance_checked_at',
|
||||
'provenance_tool_version',
|
||||
'original_disk',
|
||||
'original_path',
|
||||
'original_sha256',
|
||||
'published_snapshot_path',
|
||||
'published_snapshot_sha256',
|
||||
'published_ever_at',
|
||||
'customer_confirmation_requested_at',
|
||||
'rules_version',
|
||||
'focus_x',
|
||||
'focus_y',
|
||||
'no_crop',
|
||||
'is_preview',
|
||||
'sort_order',
|
||||
'width',
|
||||
|
|
@ -51,6 +88,18 @@ class PressReleaseImage extends Model
|
|||
'rights_confirmed_at' => 'datetime',
|
||||
'is_ai_generated' => 'boolean',
|
||||
'ai_declaration' => AiImageDeclaration::class,
|
||||
'ai_declaration_user' => AiImageDeclaration::class,
|
||||
'ai_declaration_source' => AiDeclarationSource::class,
|
||||
'ai_declaration_locked' => 'boolean',
|
||||
'credit_source' => CreditSource::class,
|
||||
'provenance_status' => ImageProvenanceStatus::class,
|
||||
'provenance' => 'array',
|
||||
'provenance_checked_at' => 'datetime',
|
||||
'published_ever_at' => 'datetime',
|
||||
'customer_confirmation_requested_at' => 'datetime',
|
||||
'focus_x' => 'integer',
|
||||
'focus_y' => 'integer',
|
||||
'no_crop' => 'boolean',
|
||||
'is_preview' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'width' => 'integer',
|
||||
|
|
@ -67,6 +116,60 @@ class PressReleaseImage extends Model
|
|||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphMany<ImageRightsConfirmation, $this>
|
||||
*/
|
||||
public function rightsConfirmations(): MorphMany
|
||||
{
|
||||
return $this->morphMany(ImageRightsConfirmation::class, 'subject');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MorphOne<ImageRightsConfirmation, $this>
|
||||
*/
|
||||
public function latestRightsConfirmation(): MorphOne
|
||||
{
|
||||
return $this->morphOne(ImageRightsConfirmation::class, 'subject')->latestOfMany('confirmed_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestand vor Formular v2: kein Beweisoriginal, kein Protokoll. Nach der
|
||||
* Legacy-Bereinigung (E.5) sind das nur noch Bilder veröffentlichter PMs.
|
||||
*/
|
||||
public function isLegacyRules(): bool
|
||||
{
|
||||
return $this->rules_version === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Bestätigung zählt nur, wenn sie zur aktuellen Datei gehört
|
||||
* (Feedback F09): Bildtausch verlangt eine neue Bestätigung.
|
||||
*/
|
||||
public function hasValidRightsConfirmation(): bool
|
||||
{
|
||||
$confirmation = $this->relationLoaded('latestRightsConfirmation')
|
||||
? $this->latestRightsConfirmation
|
||||
: $this->latestRightsConfirmation()->first();
|
||||
|
||||
return $confirmation !== null
|
||||
&& $this->original_sha256 !== null
|
||||
&& hash_equals($this->original_sha256, (string) $confirmation->file_sha256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-Upload, dessen Kundenbestätigung per Magic-Link noch aussteht
|
||||
* (Decision-Update E10). Blockiert die Veröffentlichung (E.1).
|
||||
*/
|
||||
public function awaitsCustomerConfirmation(): bool
|
||||
{
|
||||
return $this->customer_confirmation_requested_at !== null && ! $this->hasValidRightsConfirmation();
|
||||
}
|
||||
|
||||
public function wasEverPublished(): bool
|
||||
{
|
||||
return $this->published_ever_at !== null;
|
||||
}
|
||||
|
||||
public function url(bool $signed = false): ?string
|
||||
{
|
||||
if (blank($this->path)) {
|
||||
|
|
|
|||
28
app/Observers/ImageRightsConfirmationObserver.php
Normal file
28
app/Observers/ImageRightsConfirmationObserver.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Hält das Bestätigungsprotokoll insert-only (Bildkennzeichnung A.2). Eine
|
||||
* Löschung ist nur über purgeForUnpublished() erlaubt — die Sofortlöschung
|
||||
* nie veröffentlichter Uploads (Decision-Update E4).
|
||||
*/
|
||||
class ImageRightsConfirmationObserver
|
||||
{
|
||||
public function updating(ImageRightsConfirmation $confirmation): void
|
||||
{
|
||||
throw new LogicException('image_rights_confirmations ist insert-only; eine neue Bestätigung ist eine neue Zeile.');
|
||||
}
|
||||
|
||||
public function deleting(ImageRightsConfirmation $confirmation): void
|
||||
{
|
||||
if ($confirmation->purgingUnpublished) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads dürfen über purgeForUnpublished() verschwinden.');
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ use App\Models\CategoryTranslation;
|
|||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -32,6 +33,7 @@ use App\Observers\ActivityLogObserver;
|
|||
use App\Observers\AdminPerformanceCacheObserver;
|
||||
use App\Observers\CompanyPublicCacheObserver;
|
||||
use App\Observers\EmailDomainObserver;
|
||||
use App\Observers\ImageRightsConfirmationObserver;
|
||||
use App\Observers\PressReleaseSearchObserver;
|
||||
use App\Services\Admin\AdminRequestPerformanceMetrics;
|
||||
use App\Services\Mail\DnsResolver;
|
||||
|
|
@ -216,6 +218,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
// damit press_releases ohne teuren Rebuild erweitert werden kann.
|
||||
PressRelease::observe(PressReleaseSearchObserver::class);
|
||||
|
||||
// Bestätigungsprotokoll je Bild bleibt insert-only (Bildkennzeichnung A.2).
|
||||
ImageRightsConfirmation::observe(ImageRightsConfirmationObserver::class);
|
||||
|
||||
// Kontakterreichbarkeit: DNS-Befund der Mail-Domain nachziehen,
|
||||
// sobald eine Adresse entsteht oder sich ändert.
|
||||
Company::observe(EmailDomainObserver::class);
|
||||
|
|
|
|||
360
app/Services/Image/ImageProvenanceInspector.php
Normal file
360
app/Services/Image/ImageProvenanceInspector.php
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageProvenanceStatus;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Str;
|
||||
use Imagick;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Liest Herkunftsangaben aus einer Bilddatei (Bildkennzeichnung A.5).
|
||||
*
|
||||
* Phase 1: IPTC-IIM, XMP und EXIF über Imagick und die exif-Erweiterung —
|
||||
* alles frei editierbare Angaben, deshalb höchstens `unsigned_metadata`.
|
||||
* Sie belegen das Formular vor, sperren aber nichts (Decision-Update E9).
|
||||
* Die C2PA-Signaturprüfung mit `c2patool` kommt in Phase 2 (Block I).
|
||||
*
|
||||
* Zuordnung des IPTC DigitalSourceType (Feedback F02/F03):
|
||||
* - trainedAlgorithmicMedia → Vorschlag KI-generiert
|
||||
* - compositeWithTrainedAlgorithmicMedia → Vorschlag KI-bearbeitet
|
||||
* - algorithmicallyEnhanced → nur Vermerk, KEIN Vorschlag (umfasst auch
|
||||
* gewöhnliches Schärfen und Entrauschen)
|
||||
* - digitalCapture, negativeFilm, positiveFilm, print → Vorschlag „ohne KI"
|
||||
*
|
||||
* Nicht ausgewertet: CreatorTool-Strings, Dateinamen, Bildinhalt.
|
||||
*/
|
||||
class ImageProvenanceInspector
|
||||
{
|
||||
public const CREDIT_MAX_LENGTH = 255;
|
||||
|
||||
private const IPTC_NS = 'http://cv.iptc.org/newscodes/digitalsourcetype/';
|
||||
|
||||
/** @var array<string, string|null> Kurzform → Vorschlag */
|
||||
private const SOURCE_TYPE_SUGGESTIONS = [
|
||||
'trainedAlgorithmicMedia' => AiImageDeclaration::AiGenerated->value,
|
||||
'compositeWithTrainedAlgorithmicMedia' => AiImageDeclaration::AiEdited->value,
|
||||
'digitalCapture' => AiImageDeclaration::Original->value,
|
||||
'negativeFilm' => AiImageDeclaration::Original->value,
|
||||
'positiveFilm' => AiImageDeclaration::Original->value,
|
||||
'print' => AiImageDeclaration::Original->value,
|
||||
'algorithmicallyEnhanced' => null,
|
||||
];
|
||||
|
||||
public function inspect(string $absolutePath): ProvenanceResult
|
||||
{
|
||||
$started = hrtime(true);
|
||||
$sha256 = (string) hash_file('sha256', $absolutePath);
|
||||
$tool = $this->toolVersion();
|
||||
|
||||
try {
|
||||
// Eine Datei, die Imagick nicht einmal öffnen kann, ist „nicht
|
||||
// prüfbar", nicht „ohne Herkunftsdaten" (Feedback F10).
|
||||
$profiles = $this->readProfiles($absolutePath);
|
||||
|
||||
$iptc = $this->readIptc($absolutePath, $profiles['iptc']);
|
||||
$xmp = $this->readXmp($profiles['xmp']);
|
||||
$exif = $this->readExif($absolutePath);
|
||||
|
||||
$merged = [
|
||||
'digital_source_type' => $xmp['digital_source_type'] ?? null,
|
||||
'creator' => $xmp['creator'] !== [] ? $xmp['creator'] : $iptc['byline_list'],
|
||||
'credit' => $xmp['credit'] ?? $iptc['credit'],
|
||||
'byline' => $iptc['byline'],
|
||||
'copyright_notice' => $xmp['rights'] ?? $iptc['copyright_notice'],
|
||||
];
|
||||
|
||||
$derived = $this->derive($merged, $exif);
|
||||
$hasMetadata = $merged['digital_source_type'] !== null
|
||||
|| $merged['creator'] !== []
|
||||
|| $merged['credit'] !== null
|
||||
|| $merged['byline'] !== null
|
||||
|| $merged['copyright_notice'] !== null
|
||||
|| $exif['artist'] !== null
|
||||
|| $exif['copyright'] !== null;
|
||||
|
||||
$durationMs = $this->elapsedMs($started);
|
||||
|
||||
if ($durationMs > (int) config('images.provenance_timeout_seconds', 10) * 1000) {
|
||||
return $this->errorResult($sha256, $tool, $durationMs, 'timeout', 'Herkunftsprüfung hat das Zeitbudget überschritten.');
|
||||
}
|
||||
|
||||
return new ProvenanceResult(
|
||||
status: $hasMetadata ? ImageProvenanceStatus::UnsignedMetadata : ImageProvenanceStatus::Absent,
|
||||
fileSha256: $sha256,
|
||||
iptc: $merged,
|
||||
exif: $exif,
|
||||
derived: $derived,
|
||||
toolVersion: $tool,
|
||||
durationMs: $durationMs,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->errorResult($sha256, $tool, $this->elapsedMs($started), $exception::class, Str::limit($exception->getMessage(), 200));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{digital_source_type: string|null, creator: list<string>, credit: string|null, byline: string|null, copyright_notice: string|null} $iptc
|
||||
* @param array{artist: string|null, copyright: string|null} $exif
|
||||
* @return array{ai_declaration_suggestion: string|null, credit_suggestion: string|null, credit_suggestion_source: string|null, credit_truncated: bool, enhancement_hint: bool}
|
||||
*/
|
||||
private function derive(array $iptc, array $exif): array
|
||||
{
|
||||
$sourceType = $iptc['digital_source_type'];
|
||||
$suggestion = $sourceType !== null ? (self::SOURCE_TYPE_SUGGESTIONS[$sourceType] ?? null) : null;
|
||||
|
||||
// Reihenfolge des Nachweis-Vorschlags (Vorschlag 3.3, Feedback F12):
|
||||
// Credit → dc:creator → By-line → EXIF Artist → CopyrightNotice.
|
||||
$candidates = [
|
||||
'credit' => $iptc['credit'],
|
||||
'creator' => $iptc['creator'] !== [] ? implode(', ', $iptc['creator']) : null,
|
||||
'byline' => $iptc['byline'],
|
||||
'exif_artist' => $exif['artist'],
|
||||
'copyright_notice' => $iptc['copyright_notice'],
|
||||
];
|
||||
|
||||
$creditSuggestion = null;
|
||||
$creditSource = null;
|
||||
|
||||
foreach ($candidates as $source => $candidate) {
|
||||
if (filled($candidate)) {
|
||||
$creditSuggestion = trim((string) $candidate);
|
||||
$creditSource = $source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = $creditSuggestion !== null && mb_strlen($creditSuggestion) > self::CREDIT_MAX_LENGTH;
|
||||
|
||||
return [
|
||||
'ai_declaration_suggestion' => $suggestion,
|
||||
'credit_suggestion' => $truncated ? mb_substr($creditSuggestion, 0, self::CREDIT_MAX_LENGTH) : $creditSuggestion,
|
||||
'credit_suggestion_source' => $creditSource,
|
||||
'credit_truncated' => $truncated,
|
||||
'enhancement_hint' => $sourceType === 'algorithmicallyEnhanced',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet die Datei einmal mit Imagick und liest die eingebetteten
|
||||
* IPTC- und XMP-Profile. Wirft, wenn die Datei kein lesbares Bild ist.
|
||||
*
|
||||
* @return array{iptc: string|null, xmp: string|null}
|
||||
*/
|
||||
private function readProfiles(string $absolutePath): array
|
||||
{
|
||||
if (! class_exists(Imagick::class)) {
|
||||
throw new \RuntimeException('Imagick ist nicht verfügbar.');
|
||||
}
|
||||
|
||||
$imagick = new Imagick;
|
||||
|
||||
try {
|
||||
$imagick->readImage($absolutePath);
|
||||
$profiles = $imagick->getImageProfiles('*', true);
|
||||
} finally {
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
return [
|
||||
'iptc' => is_string($profiles['iptc'] ?? null) && $profiles['iptc'] !== '' ? $profiles['iptc'] : null,
|
||||
'xmp' => is_string($profiles['xmp'] ?? null) && trim($profiles['xmp']) !== '' ? $profiles['xmp'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* IPTC-IIM: bei JPEG bevorzugt über den APP13-Block (getimagesize),
|
||||
* sonst über das Imagick-Profil. Geparst mit iptcparse().
|
||||
*
|
||||
* @return array{credit: string|null, byline: string|null, byline_list: list<string>, copyright_notice: string|null}
|
||||
*/
|
||||
private function readIptc(string $absolutePath, ?string $profile): array
|
||||
{
|
||||
$empty = ['credit' => null, 'byline' => null, 'byline_list' => [], 'copyright_notice' => null];
|
||||
|
||||
$block = $profile;
|
||||
$info = [];
|
||||
|
||||
if (@getimagesize($absolutePath, $info) !== false && isset($info['APP13'])) {
|
||||
$block = $info['APP13'];
|
||||
}
|
||||
|
||||
if ($block === null) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$parsed = @iptcparse($block);
|
||||
|
||||
if (! is_array($parsed)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$first = fn (string $key): ?string => isset($parsed[$key][0]) ? $this->cleanText($parsed[$key][0]) : null;
|
||||
$bylines = array_values(array_filter(array_map(fn ($value) => $this->cleanText((string) $value), $parsed['2#080'] ?? [])));
|
||||
|
||||
return [
|
||||
'credit' => $first('2#110'),
|
||||
'byline' => $bylines[0] ?? null,
|
||||
'byline_list' => $bylines,
|
||||
'copyright_notice' => $first('2#116'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* XMP aus dem Imagick-Profil (JPEG, PNG, WebP). Ausgewertet werden
|
||||
* photoshop:Credit, dc:creator, dc:rights und Iptc4xmpExt:DigitalSourceType,
|
||||
* jeweils als Element oder als Attribut von rdf:Description.
|
||||
*
|
||||
* @return array{credit: string|null, creator: list<string>, rights: string|null, digital_source_type: string|null}
|
||||
*/
|
||||
private function readXmp(?string $xmp): array
|
||||
{
|
||||
$empty = ['credit' => null, 'creator' => [], 'rights' => null, 'digital_source_type' => null];
|
||||
|
||||
if ($xmp === null) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$document = new DOMDocument;
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$loaded = $document->loadXML($xmp, LIBXML_NONET | LIBXML_NOENT);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
if (! $loaded) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($document);
|
||||
$xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
||||
$xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
|
||||
$xpath->registerNamespace('photoshop', 'http://ns.adobe.com/photoshop/1.0/');
|
||||
$xpath->registerNamespace('Iptc4xmpExt', 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/');
|
||||
|
||||
$text = function (string $query) use ($xpath): ?string {
|
||||
$nodes = $xpath->query($query);
|
||||
|
||||
return $nodes !== false && $nodes->length > 0 ? $this->cleanText($nodes->item(0)->textContent) : null;
|
||||
};
|
||||
|
||||
$list = function (string $query) use ($xpath): array {
|
||||
$nodes = $xpath->query($query);
|
||||
$values = [];
|
||||
|
||||
if ($nodes !== false) {
|
||||
foreach ($nodes as $node) {
|
||||
$value = $this->cleanText($node->textContent);
|
||||
|
||||
if ($value !== null) {
|
||||
$values[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
};
|
||||
|
||||
$creator = $list('//dc:creator//rdf:li');
|
||||
if ($creator === [] && ($single = $text('//rdf:Description/@dc:creator')) !== null) {
|
||||
$creator = [$single];
|
||||
}
|
||||
|
||||
return [
|
||||
'credit' => $text('//photoshop:Credit') ?? $text('//rdf:Description/@photoshop:Credit'),
|
||||
'creator' => $creator,
|
||||
'rights' => $text('//dc:rights//rdf:li') ?? $text('//rdf:Description/@dc:rights'),
|
||||
'digital_source_type' => $this->normalizeSourceType(
|
||||
$text('//Iptc4xmpExt:DigitalSourceType') ?? $text('//rdf:Description/@Iptc4xmpExt:DigitalSourceType'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{artist: string|null, copyright: string|null}
|
||||
*/
|
||||
private function readExif(string $absolutePath): array
|
||||
{
|
||||
$empty = ['artist' => null, 'copyright' => null];
|
||||
|
||||
if (! function_exists('exif_read_data')) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$mime = (string) (@mime_content_type($absolutePath) ?: '');
|
||||
|
||||
if (! in_array($mime, ['image/jpeg', 'image/tiff'], true)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$data = @exif_read_data($absolutePath, 'IFD0', true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
return [
|
||||
'artist' => $this->cleanText((string) ($data['IFD0']['Artist'] ?? '')),
|
||||
'copyright' => $this->cleanText((string) ($data['IFD0']['Copyright'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständige IPTC-URIs und Kurzformen auf die Kurzform bringen
|
||||
* (Feedback F03: nicht nur unqualifizierte Werte vergleichen).
|
||||
*/
|
||||
private function normalizeSourceType(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
if (Str::startsWith($value, [self::IPTC_NS, str_replace('http://', 'https://', self::IPTC_NS)])) {
|
||||
$value = Str::afterLast($value, '/');
|
||||
}
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function cleanText(string $value): ?string
|
||||
{
|
||||
$value = trim(preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '');
|
||||
|
||||
if ($value !== '' && ! mb_check_encoding($value, 'UTF-8')) {
|
||||
$value = mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
|
||||
}
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function toolVersion(): string
|
||||
{
|
||||
$imagick = class_exists(Imagick::class) ? Imagick::getVersion()['versionString'] : 'no-imagick';
|
||||
$imagick = trim(Str::before(Str::after($imagick, 'ImageMagick '), ' ')) ?: 'imagick';
|
||||
|
||||
return sprintf('imagick %s / exif %s', $imagick, function_exists('exif_read_data') ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
private function elapsedMs(int $startedNs): int
|
||||
{
|
||||
return (int) round((hrtime(true) - $startedNs) / 1_000_000);
|
||||
}
|
||||
|
||||
private function errorResult(string $sha256, string $tool, int $durationMs, string $class, string $message): ProvenanceResult
|
||||
{
|
||||
return new ProvenanceResult(
|
||||
status: ImageProvenanceStatus::Error,
|
||||
fileSha256: $sha256,
|
||||
iptc: ['digital_source_type' => null, 'creator' => [], 'credit' => null, 'byline' => null, 'copyright_notice' => null],
|
||||
exif: ['artist' => null, 'copyright' => null],
|
||||
derived: ['ai_declaration_suggestion' => null, 'credit_suggestion' => null, 'credit_suggestion_source' => null, 'credit_truncated' => false, 'enhancement_hint' => false],
|
||||
toolVersion: $tool,
|
||||
durationMs: $durationMs,
|
||||
error: ['class' => $class, 'message' => $message],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
app/Services/Image/ImageRightsTextRenderer.php
Normal file
58
app/Services/Image/ImageRightsTextRenderer.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\LegalText;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Löst die Platzhalter des Bestätigungstextes auf (Bildkennzeichnung A.3,
|
||||
* Decision-Update S11): AGB-Fassung, Datum und Link kommen aus
|
||||
* `legal_documents`, die Portalnamen aus der Konfiguration. Der gerenderte
|
||||
* Wortlaut wird mit der Bestätigung gespeichert.
|
||||
*/
|
||||
class ImageRightsTextRenderer
|
||||
{
|
||||
/**
|
||||
* Der heute abzugebende Text: gebunden an die aktuell gültige AGB-Fassung.
|
||||
*/
|
||||
public function current(string $locale = 'de'): ?LegalText
|
||||
{
|
||||
return LegalText::currentFor(
|
||||
LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION,
|
||||
LegalDocument::currentAgbVersion(),
|
||||
$locale,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{agb_version: string, agb_date: string, agb_url: string, portals: string}
|
||||
*/
|
||||
public function variables(?string $agbUrl = null): array
|
||||
{
|
||||
$agb = LegalDocument::current(LegalDocument::TYPE_AGB);
|
||||
|
||||
$version = $agb !== null ? $agb->version : LegalDocument::currentAgbVersion();
|
||||
$date = $agb !== null
|
||||
? $agb->effectiveFromFormatted()
|
||||
: Carbon::parse((string) config('legal.agb_effective_from'))->locale('de')->isoFormat('D. MMMM YYYY');
|
||||
|
||||
return [
|
||||
'agb_version' => $version,
|
||||
'agb_date' => $date,
|
||||
'agb_url' => $agbUrl ?? rtrim((string) config('app.url'), '/').'/de/agb',
|
||||
'portals' => 'presseecho.de und businessportal24.com',
|
||||
];
|
||||
}
|
||||
|
||||
public function renderLong(LegalText $text, ?string $agbUrl = null): string
|
||||
{
|
||||
return $text->render($this->variables($agbUrl));
|
||||
}
|
||||
|
||||
public function renderShort(LegalText $text, ?string $agbUrl = null): string
|
||||
{
|
||||
return $text->render($this->variables($agbUrl), short: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,8 +45,19 @@ class ImageService
|
|||
'large' => ['width' => 1600, 'height' => 1200],
|
||||
// Titelbild (Hero) der Detailansicht: harte Obergrenze 1280x580 px.
|
||||
'cover' => ['width' => 1280, 'height' => 580],
|
||||
// OpenGraph-Vorschau (Decision-Update E7): bekommt in H.1 den
|
||||
// eingebrannten Nachweisbalken; bis dahin ein normaler Zuschnitt.
|
||||
'og' => ['width' => 1200, 'height' => 630],
|
||||
];
|
||||
|
||||
/**
|
||||
* Zuschnitt-Steuerung je Bild (Bildkennzeichnung H.3, § 9 Abs. 1 AGB):
|
||||
* Fokuspunkt in Prozent der Bildbreite/-höhe, Standard Bildmitte.
|
||||
*
|
||||
* @var array{x: int, y: int}
|
||||
*/
|
||||
public const DEFAULT_FOCUS = ['x' => 50, 'y' => 50];
|
||||
|
||||
public const ALLOWED_LOGO_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
|
|
@ -107,9 +118,17 @@ class ImageService
|
|||
|
||||
/**
|
||||
* Persists a freshly uploaded press release image, generates all variants
|
||||
* and discards the original upload. The canonical stored path points to
|
||||
* the cover variant to keep storage usage predictable.
|
||||
* and discards the working copy in the derivative folder. The canonical
|
||||
* stored path points to the cover variant to keep storage usage
|
||||
* predictable.
|
||||
*
|
||||
* Das Beweisoriginal liegt NICHT hier: Der Uploader (Bildkennzeichnung
|
||||
* A.6) sichert es vorher auf den privaten Originalpfad. Scheitert die
|
||||
* Cover-Variante, wird abgebrochen — kein Rückfall auf die Quelldatei,
|
||||
* damit nie ein unbereinigtes Original öffentlich wird (Feedback F05).
|
||||
*
|
||||
* @param array{x: int, y: int}|null $focus Fokuspunkt in Prozent (H.3)
|
||||
* @param bool $noCrop Letterbox statt Zuschnitt (H.3)
|
||||
* @return array{
|
||||
* disk: string,
|
||||
* path: string,
|
||||
|
|
@ -123,6 +142,8 @@ class ImageService
|
|||
UploadedFile $upload,
|
||||
int $pressReleaseId,
|
||||
string $diskName = 'local',
|
||||
?array $focus = null,
|
||||
bool $noCrop = false,
|
||||
): array {
|
||||
$this->assertValidPressReleaseImageUpload($upload);
|
||||
|
||||
|
|
@ -137,22 +158,34 @@ class ImageService
|
|||
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
$variants = $this->generateVariants(
|
||||
$disk,
|
||||
$relativePath,
|
||||
$extension,
|
||||
self::PRESS_RELEASE_IMAGE_VARIANTS,
|
||||
cover: true,
|
||||
);
|
||||
try {
|
||||
$variants = $this->generateVariants(
|
||||
$disk,
|
||||
$relativePath,
|
||||
$extension,
|
||||
self::PRESS_RELEASE_IMAGE_VARIANTS,
|
||||
cover: true,
|
||||
focus: $focus,
|
||||
noCrop: $noCrop,
|
||||
);
|
||||
} finally {
|
||||
// Die Arbeitskopie im Derivat-Ordner wird immer entfernt — auch
|
||||
// bei Fehlern, damit nichts Unbereinigtes liegen bleibt.
|
||||
if ($disk->exists($relativePath)) {
|
||||
$disk->delete($relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$coverPath = $variants['cover'] ?? $relativePath;
|
||||
if (! isset($variants['cover'])) {
|
||||
$this->deleteWithVariants(null, $variants, $diskName);
|
||||
|
||||
throw new RuntimeException('Das Bild konnte nicht verarbeitet werden (Titelbild-Variante fehlt).');
|
||||
}
|
||||
|
||||
$coverPath = $variants['cover'];
|
||||
$coverAbsolute = $disk->path($coverPath);
|
||||
$coverSize = @getimagesize($coverAbsolute) ?: [null, null];
|
||||
|
||||
if ($coverPath !== $relativePath && $disk->exists($relativePath)) {
|
||||
$disk->delete($relativePath);
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => $diskName,
|
||||
'path' => $coverPath,
|
||||
|
|
@ -244,12 +277,14 @@ class ImageService
|
|||
/**
|
||||
* Generic variant generator. `cover` switches between contained
|
||||
* (transparent letterbox, used for logos) and cover (cropped to fill,
|
||||
* used for press release imagery).
|
||||
* used for press release imagery). `focus` verschiebt den Zuschnitt,
|
||||
* `noCrop` erzwingt Letterbox auch für Pressebilder (H.3).
|
||||
*
|
||||
* @param array<string, array{width: int, height: int}> $variantSpecs
|
||||
* @param array{x: int, y: int}|null $focus
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false): array
|
||||
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false, ?array $focus = null, bool $noCrop = false): array
|
||||
{
|
||||
$absolute = $disk->path($relativePath);
|
||||
$sourceImage = $this->createImageResource($absolute, $extension);
|
||||
|
|
@ -279,9 +314,9 @@ class ImageService
|
|||
$targetHeight = max(1, (int) round($targetHeight * $scale));
|
||||
}
|
||||
|
||||
$resized = $cover
|
||||
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight)
|
||||
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight);
|
||||
$resized = $cover && ! $noCrop
|
||||
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight, $focus)
|
||||
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight, opaque: $cover && $extension === 'jpg');
|
||||
|
||||
$written = $this->writeImage($resized, $variantAbsolute, $extension);
|
||||
|
||||
|
|
@ -327,7 +362,7 @@ class ImageService
|
|||
* Resizes preserving aspect ratio with transparent letterbox/pillarbox
|
||||
* so logos always render fully on a square or 2:1 canvas.
|
||||
*/
|
||||
private function resizeContained(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
|
||||
private function resizeContained(\GdImage $source, int $targetWidth, int $targetHeight, bool $opaque = false): \GdImage
|
||||
{
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
|
|
@ -344,8 +379,12 @@ class ImageService
|
|||
imagealphablending($canvas, false);
|
||||
imagesavealpha($canvas, true);
|
||||
|
||||
$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
|
||||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $transparent);
|
||||
// JPEG kennt keine Transparenz: neutraler heller Rand für die
|
||||
// Letterbox (no_crop, H.3); sonst transparent wie bei Logos.
|
||||
$background = $opaque
|
||||
? imagecolorallocate($canvas, 244, 244, 245)
|
||||
: imagecolorallocatealpha($canvas, 0, 0, 0, 127);
|
||||
imagefilledrectangle($canvas, 0, 0, $targetWidth, $targetHeight, $background);
|
||||
|
||||
imagealphablending($canvas, true);
|
||||
|
||||
|
|
@ -367,10 +406,13 @@ class ImageService
|
|||
|
||||
/**
|
||||
* Cover-resize: scale source so that the target box is fully filled, then
|
||||
* crop the overflow centered. Used for press release imagery so listings
|
||||
* always render rectangles without empty bars.
|
||||
* crop the overflow around the focus point (default: centered). Used for
|
||||
* press release imagery so listings always render rectangles without
|
||||
* empty bars.
|
||||
*
|
||||
* @param array{x: int, y: int}|null $focus Fokuspunkt in Prozent (H.3)
|
||||
*/
|
||||
private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight): \GdImage
|
||||
private function resizeCover(\GdImage $source, int $targetWidth, int $targetHeight, ?array $focus = null): \GdImage
|
||||
{
|
||||
$sourceWidth = imagesx($source);
|
||||
$sourceHeight = imagesy($source);
|
||||
|
|
@ -379,8 +421,13 @@ class ImageService
|
|||
$sampledWidth = max(1, (int) round($targetWidth / $scale));
|
||||
$sampledHeight = max(1, (int) round($targetHeight / $scale));
|
||||
|
||||
$sourceX = (int) round(($sourceWidth - $sampledWidth) / 2);
|
||||
$sourceY = (int) round(($sourceHeight - $sampledHeight) / 2);
|
||||
$focusX = max(0, min(100, (int) ($focus['x'] ?? self::DEFAULT_FOCUS['x']))) / 100;
|
||||
$focusY = max(0, min(100, (int) ($focus['y'] ?? self::DEFAULT_FOCUS['y']))) / 100;
|
||||
|
||||
// Der Ausschnitt wird so gelegt, dass der Fokuspunkt möglichst an
|
||||
// derselben relativen Position bleibt; an den Rändern wird geklemmt.
|
||||
$sourceX = (int) round(max(0, min($sourceWidth - $sampledWidth, $sourceWidth * $focusX - $sampledWidth * $focusX)));
|
||||
$sourceY = (int) round(max(0, min($sourceHeight - $sampledHeight, $sourceHeight * $focusY - $sampledHeight * $focusY)));
|
||||
|
||||
$canvas = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
|
||||
|
|
|
|||
70
app/Services/Image/ProvenanceResult.php
Normal file
70
app/Services/Image/ProvenanceResult.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageProvenanceStatus;
|
||||
|
||||
/**
|
||||
* Normalisiertes Ergebnis der Herkunftsprüfung (Bildkennzeichnung A.5).
|
||||
* Wird als json am Bild und im Bestätigungs-Snapshot gespeichert.
|
||||
*
|
||||
* @phpstan-type IptcData array{digital_source_type: string|null, creator: list<string>, credit: string|null, byline: string|null, copyright_notice: string|null}
|
||||
* @phpstan-type ExifData array{artist: string|null, copyright: string|null}
|
||||
* @phpstan-type DerivedData array{ai_declaration_suggestion: string|null, credit_suggestion: string|null, credit_suggestion_source: string|null, credit_truncated: bool, enhancement_hint: bool}
|
||||
*/
|
||||
final readonly class ProvenanceResult
|
||||
{
|
||||
/**
|
||||
* @param IptcData $iptc
|
||||
* @param ExifData $exif
|
||||
* @param DerivedData $derived
|
||||
* @param array{class: string, message: string}|null $error
|
||||
*/
|
||||
public function __construct(
|
||||
public ImageProvenanceStatus $status,
|
||||
public string $fileSha256,
|
||||
public array $iptc,
|
||||
public array $exif,
|
||||
public array $derived,
|
||||
public string $toolVersion,
|
||||
public int $durationMs,
|
||||
public ?array $error = null,
|
||||
) {}
|
||||
|
||||
public function suggestedAiDeclaration(): ?AiImageDeclaration
|
||||
{
|
||||
return AiImageDeclaration::tryFrom((string) $this->derived['ai_declaration_suggestion']);
|
||||
}
|
||||
|
||||
public function creditSuggestion(): ?string
|
||||
{
|
||||
return $this->derived['credit_suggestion'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur ein vertrauenswürdig geprüfter Befund darf die Kennzeichnung
|
||||
* erzwingen (Decision-Update E9). In Phase 1 immer null.
|
||||
*/
|
||||
public function enforcedAiDeclaration(): ?AiImageDeclaration
|
||||
{
|
||||
return $this->status->allowsLock() ? $this->suggestedAiDeclaration() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'status' => $this->status->value,
|
||||
'file_sha256' => $this->fileSha256,
|
||||
'tool' => $this->toolVersion,
|
||||
'duration_ms' => $this->durationMs,
|
||||
'iptc' => $this->iptc,
|
||||
'exif' => $this->exif,
|
||||
'derived' => $this->derived,
|
||||
'error' => $this->error,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
30
app/Services/PressRelease/Images/ImageSubmission.php
Normal file
30
app/Services/PressRelease/Images/ImageSubmission.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
|
||||
/**
|
||||
* Angaben des Einreichers zu einem Bild (Bildkennzeichnung A.6). Formular,
|
||||
* API und Magic-Link-Bestätigung bauen dasselbe Objekt; der Uploader
|
||||
* bestimmt daraus die effektiven Werte serverseitig.
|
||||
*/
|
||||
final readonly class ImageSubmission
|
||||
{
|
||||
/**
|
||||
* @param array{x: int, y: int}|null $focus
|
||||
*/
|
||||
public function __construct(
|
||||
public string $copyright,
|
||||
public ?AiImageDeclaration $aiDeclaration,
|
||||
public bool $rightsConfirmed,
|
||||
public ?string $legalTextKey,
|
||||
public ?string $title = null,
|
||||
public ?string $description = null,
|
||||
public ?string $licenseUrl = null,
|
||||
public ?string $sourceUrl = null,
|
||||
public ?array $focus = null,
|
||||
public bool $noCrop = false,
|
||||
public bool $creditFromSuggestion = false,
|
||||
) {}
|
||||
}
|
||||
46
app/Services/PressRelease/Images/ImageUploadActor.php
Normal file
46
app/Services/PressRelease/Images/ImageUploadActor.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Wer ein Bild hochlädt und über welchen Weg (Bildkennzeichnung A.6).
|
||||
*
|
||||
* `channel = null` ist der Admin-Upload: Der Admin bestätigt nichts, das
|
||||
* Bild wartet auf die Kundenbestätigung per Magic-Link (Decision-Update E10).
|
||||
* `user` ist bei Form und API der Kontoinhaber und damit der
|
||||
* Vertragspartner (E1); beim Admin-Upload der handelnde Admin.
|
||||
*/
|
||||
final readonly class ImageUploadActor
|
||||
{
|
||||
public function __construct(
|
||||
public ?User $user,
|
||||
public ?ConfirmationChannel $channel,
|
||||
public ?int $tokenId = null,
|
||||
public ?string $ip = null,
|
||||
public ?string $userAgent = null,
|
||||
public bool $isAdmin = false,
|
||||
) {}
|
||||
|
||||
public static function form(User $user, ?string $ip, ?string $userAgent): self
|
||||
{
|
||||
return new self($user, ConfirmationChannel::Form, null, $ip, $userAgent);
|
||||
}
|
||||
|
||||
public static function api(User $user, ?int $tokenId, ?string $ip, ?string $userAgent): self
|
||||
{
|
||||
return new self($user, ConfirmationChannel::Api, $tokenId, $ip, $userAgent);
|
||||
}
|
||||
|
||||
public static function admin(User $admin, ?string $ip, ?string $userAgent): self
|
||||
{
|
||||
return new self($admin, null, null, $ip, $userAgent, isAdmin: true);
|
||||
}
|
||||
|
||||
public function confirmsImmediately(): bool
|
||||
{
|
||||
return $this->channel !== null;
|
||||
}
|
||||
}
|
||||
17
app/Services/PressRelease/Images/ImageUploadException.php
Normal file
17
app/Services/PressRelease/Images/ImageUploadException.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Fachlicher Fehler beim Bild-Upload (Grenzen, Titelbild-Regel, fehlende
|
||||
* Bestätigung, Hash-Abweichung). Die Meldung ist für den Einreicher gedacht.
|
||||
*/
|
||||
class ImageUploadException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, public readonly string $field = 'image')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
327
app/Services/PressRelease/Images/PressReleaseImageUploader.php
Normal file
327
app/Services/PressRelease/Images/PressReleaseImageUploader.php
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\CreditSource;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalText;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Image\ImageProvenanceInspector;
|
||||
use App\Services\Image\ImageRightsTextRenderer;
|
||||
use App\Services\Image\ImageService;
|
||||
use App\Services\Image\ProvenanceResult;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Gemeinsamer Upload-Ablauf für Titelbilder (Bildkennzeichnung A.6), den
|
||||
* Formular, API v1 und Admin-Upload gleichermaßen nutzen:
|
||||
*
|
||||
* 1. Grenzen prüfen (E-B6), Bytes einmal lesen, SHA-256 bilden.
|
||||
* 2. Beweisoriginal auf den privaten Originalpfad schreiben (E9, F05).
|
||||
* 3. Herkunftsprüfung auf dieselben Bytes (A.5).
|
||||
* 4. Derivate über ImageService; bei Fehler alles zurückrollen.
|
||||
* 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund
|
||||
* getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor.
|
||||
* 6. Transaktion mit Sperre auf der PM: ein Titelbild, Bildzeile,
|
||||
* Bestätigung (außer Admin-Upload), Regelversion.
|
||||
* 7. DB-Fehler → Dateien wieder löschen.
|
||||
*
|
||||
* Für Newsroom-Bilder (Block J) wird das Subjekt verallgemeinert; Phase 1
|
||||
* kennt nur PressRelease.
|
||||
*/
|
||||
class PressReleaseImageUploader
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImageService $imageService,
|
||||
private readonly ImageProvenanceInspector $inspector,
|
||||
private readonly ImageRightsTextRenderer $textRenderer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Vorprüfung nach der Dateiauswahl (B.2): Grenzen und Herkunft, ohne
|
||||
* etwas zu speichern. Das Ergebnis belegt das Formular vor; beim
|
||||
* Speichern wird auf den finalen Bytes neu gerechnet.
|
||||
*/
|
||||
public function preflight(UploadedFile $upload): ProvenanceResult
|
||||
{
|
||||
$this->assertWithinLimits($upload);
|
||||
|
||||
return $this->inspector->inspect($upload->getRealPath());
|
||||
}
|
||||
|
||||
public function store(
|
||||
UploadedFile $upload,
|
||||
PressRelease $pressRelease,
|
||||
ImageSubmission $submission,
|
||||
ImageUploadActor $actor,
|
||||
?string $expectedSha256 = null,
|
||||
?string $agbUrl = null,
|
||||
): PressReleaseImage {
|
||||
$this->assertWithinLimits($upload);
|
||||
|
||||
$sourcePath = $upload->getRealPath();
|
||||
$sha256 = (string) hash_file('sha256', $sourcePath);
|
||||
|
||||
// Bindung an die geprüfte Datei (F09): Wer zwischen Auswahl und
|
||||
// Speichern die Datei tauscht, bekommt keine alte Vorbelegung.
|
||||
if ($expectedSha256 !== null && ! hash_equals($expectedSha256, $sha256)) {
|
||||
throw new ImageUploadException(__('Die Datei hat sich seit der Prüfung geändert. Bitte wählen Sie das Bild erneut aus.'));
|
||||
}
|
||||
|
||||
$legalText = $this->resolveLegalText($submission, $actor);
|
||||
|
||||
$originalsDisk = (string) config('images.originals_disk', 'local');
|
||||
$originalPath = sprintf(
|
||||
'%s/%d/%s.%s',
|
||||
config('images.originals_directory'),
|
||||
$pressRelease->id,
|
||||
Str::uuid()->toString(),
|
||||
$this->extensionFor($upload),
|
||||
);
|
||||
|
||||
$disk = Storage::disk($originalsDisk);
|
||||
$stream = fopen($sourcePath, 'rb');
|
||||
|
||||
if ($stream === false || ! $disk->put($originalPath, $stream)) {
|
||||
throw new RuntimeException('Das Original konnte nicht gesichert werden.');
|
||||
}
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
$stored = null;
|
||||
|
||||
try {
|
||||
$provenance = $this->inspector->inspect($disk->path($originalPath));
|
||||
|
||||
if (! hash_equals($sha256, $provenance->fileSha256)) {
|
||||
throw new RuntimeException('Hash von Original und Prüfung stimmen nicht überein.');
|
||||
}
|
||||
|
||||
$stored = $this->imageService->storePressReleaseImage(
|
||||
$upload,
|
||||
$pressRelease->id,
|
||||
focus: $submission->focus,
|
||||
noCrop: $submission->noCrop,
|
||||
);
|
||||
|
||||
return DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage {
|
||||
$locked = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($pressRelease->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($locked->images()->exists()) {
|
||||
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
|
||||
}
|
||||
|
||||
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
|
||||
|
||||
$image = $locked->images()->create([
|
||||
'disk' => $stored['disk'],
|
||||
'path' => $stored['path'],
|
||||
'variants' => $stored['variants'],
|
||||
'title' => $submission->title ?: null,
|
||||
'description' => $submission->description ?: null,
|
||||
'copyright' => $submission->copyright,
|
||||
'credit_source' => $this->creditSource($submission, $actor),
|
||||
'license_url' => $submission->licenseUrl ?: null,
|
||||
'source_url' => $submission->sourceUrl ?: null,
|
||||
'rights_confirmed_at' => $actor->confirmsImmediately() ? now() : null,
|
||||
'ai_declaration' => $effective['declaration'],
|
||||
'ai_declaration_user' => $submission->aiDeclaration,
|
||||
'ai_declaration_source' => $effective['source'],
|
||||
'ai_declaration_locked' => $effective['locked'],
|
||||
'provenance_status' => $provenance->status,
|
||||
'provenance' => $provenance->toArray() + ($effective['deviated'] ? ['deviated_from_prefill' => true] : []),
|
||||
'provenance_checked_at' => now(),
|
||||
'provenance_tool_version' => $provenance->toolVersion,
|
||||
'original_disk' => $originalsDisk,
|
||||
'original_path' => $originalPath,
|
||||
'original_sha256' => $sha256,
|
||||
'published_ever_at' => $locked->published_at !== null ? now() : null,
|
||||
'customer_confirmation_requested_at' => $actor->confirmsImmediately() ? null : now(),
|
||||
'rules_version' => config('images.rules_version'),
|
||||
'focus_x' => $submission->focus['x'] ?? null,
|
||||
'focus_y' => $submission->focus['y'] ?? null,
|
||||
'no_crop' => $submission->noCrop,
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
'width' => $stored['width'],
|
||||
'height' => $stored['height'],
|
||||
'mime' => $stored['mime'],
|
||||
]);
|
||||
|
||||
if ($actor->confirmsImmediately() && $legalText !== null) {
|
||||
$this->recordConfirmation($image, $locked, $actor, $legalText, $agbUrl);
|
||||
}
|
||||
|
||||
return $image;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
// Kein Rollback für Dateien: Was geschrieben wurde, wird
|
||||
// ausdrücklich wieder entfernt (Feedback F09).
|
||||
if ($stored !== null) {
|
||||
$this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']);
|
||||
}
|
||||
|
||||
$disk->delete($originalPath);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
|
||||
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
|
||||
* Ungesicherte Metadaten (Phase 1) belegen nur vor — weicht der Kunde
|
||||
* nach unten ab, wird das vermerkt, nicht verhindert.
|
||||
*
|
||||
* @return array{declaration: AiImageDeclaration|null, source: AiDeclarationSource|null, locked: bool, deviated: bool}
|
||||
*/
|
||||
private function effectiveDeclaration(?AiImageDeclaration $userChoice, ProvenanceResult $provenance): array
|
||||
{
|
||||
$enforced = $provenance->enforcedAiDeclaration();
|
||||
|
||||
if ($enforced !== null) {
|
||||
$effective = AiImageDeclaration::strictest($enforced, $userChoice);
|
||||
|
||||
return [
|
||||
'declaration' => $effective,
|
||||
'source' => $effective === $userChoice && $userChoice->rank() > $enforced->rank()
|
||||
? AiDeclarationSource::Customer
|
||||
: AiDeclarationSource::C2paVerified,
|
||||
'locked' => true,
|
||||
'deviated' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$suggestion = $provenance->suggestedAiDeclaration();
|
||||
$deviated = $suggestion !== null && $userChoice !== null && $userChoice->rank() < $suggestion->rank();
|
||||
|
||||
return [
|
||||
'declaration' => $userChoice,
|
||||
'source' => $userChoice === null
|
||||
? null
|
||||
: ($suggestion !== null && $suggestion === $userChoice ? AiDeclarationSource::IptcPrefill : AiDeclarationSource::Customer),
|
||||
'locked' => false,
|
||||
'deviated' => $deviated,
|
||||
];
|
||||
}
|
||||
|
||||
private function creditSource(ImageSubmission $submission, ImageUploadActor $actor): CreditSource
|
||||
{
|
||||
if ($submission->creditFromSuggestion) {
|
||||
return CreditSource::Metadata;
|
||||
}
|
||||
|
||||
return $actor->channel?->value === 'api' ? CreditSource::Api : CreditSource::Customer;
|
||||
}
|
||||
|
||||
private function resolveLegalText(ImageSubmission $submission, ImageUploadActor $actor): ?LegalText
|
||||
{
|
||||
if (! $actor->confirmsImmediately()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $submission->rightsConfirmed) {
|
||||
throw new ImageUploadException(__('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'), 'rights_confirmed');
|
||||
}
|
||||
|
||||
$current = $this->textRenderer->current();
|
||||
|
||||
if ($current === null) {
|
||||
throw new RuntimeException('Kein aktiver Bestätigungstext hinterlegt (LegalTextSeeder).');
|
||||
}
|
||||
|
||||
if ($submission->legalTextKey !== null && $submission->legalTextKey !== $current->key) {
|
||||
throw new ImageUploadException(
|
||||
__('Der Bestätigungstext wurde aktualisiert (aktuell :key). Bitte lesen und bestätigen Sie die neue Fassung.', ['key' => $current->key]),
|
||||
'rights_confirmation_text_version',
|
||||
);
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): void
|
||||
{
|
||||
$variables = $this->textRenderer->variables($agbUrl);
|
||||
|
||||
ImageRightsConfirmation::query()->create([
|
||||
'subject_type' => $image->getMorphClass(),
|
||||
'subject_id' => $image->getKey(),
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'company_id' => $pressRelease->company_id,
|
||||
'user_id' => $actor->user?->getKey(),
|
||||
'personal_access_token_id' => $actor->tokenId,
|
||||
'channel' => $actor->channel,
|
||||
'legal_text_id' => $legalText->getKey(),
|
||||
'legal_text_key' => $legalText->key,
|
||||
'text_snapshot' => $legalText->render($variables),
|
||||
'agb_version' => $variables['agb_version'],
|
||||
'locale' => $legalText->locale,
|
||||
'file_sha256' => $image->original_sha256,
|
||||
'declaration_snapshot' => [
|
||||
'copyright' => $image->copyright,
|
||||
'credit_source' => $image->credit_source?->value,
|
||||
'license_url' => $image->license_url,
|
||||
'source_url' => $image->source_url,
|
||||
'ai_declaration_user' => $image->ai_declaration_user?->value,
|
||||
'ai_declaration' => $image->ai_declaration?->value,
|
||||
'ai_declaration_source' => $image->ai_declaration_source?->value,
|
||||
'locked' => $image->ai_declaration_locked,
|
||||
'provenance_status' => $image->provenance_status?->value,
|
||||
'provenance' => $image->provenance,
|
||||
],
|
||||
'ip_hmac' => ImageRightsConfirmation::hashIp($actor->ip),
|
||||
'user_agent' => $actor->userAgent !== null ? Str::limit($actor->userAgent, 250, '') : null,
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function assertWithinLimits(UploadedFile $upload): void
|
||||
{
|
||||
$maxBytes = (int) config('images.max_bytes');
|
||||
|
||||
if ($upload->getSize() > $maxBytes) {
|
||||
throw new ImageUploadException(__('Das Bild ist größer als :mb MB.', ['mb' => (int) round($maxBytes / 1024 / 1024)]));
|
||||
}
|
||||
|
||||
if (! in_array($upload->getMimeType(), ImageService::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
|
||||
throw new ImageUploadException(__('Bitte laden Sie ein JPG-, PNG- oder WebP-Bild hoch.'));
|
||||
}
|
||||
|
||||
$size = @getimagesize($upload->getRealPath());
|
||||
$maxSide = (int) config('images.max_pixels_per_side');
|
||||
|
||||
if ($size === false) {
|
||||
throw new ImageUploadException(__('Die Datei konnte nicht als Bild gelesen werden.'));
|
||||
}
|
||||
|
||||
if ($size[0] > $maxSide || $size[1] > $maxSide) {
|
||||
throw new ImageUploadException(__('Das Bild darf höchstens :px Pixel je Seite haben.', ['px' => number_format($maxSide, 0, ',', '.')]));
|
||||
}
|
||||
}
|
||||
|
||||
private function extensionFor(UploadedFile $upload): string
|
||||
{
|
||||
return match ($upload->getMimeType()) {
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg',
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue