diff --git a/app/Console/Commands/BackfillImageRightsConfirmations.php b/app/Console/Commands/BackfillImageRightsConfirmations.php new file mode 100644 index 00000000..687e4a27 --- /dev/null +++ b/app/Console/Commands/BackfillImageRightsConfirmations.php @@ -0,0 +1,84 @@ +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; + } +} diff --git a/app/Enums/AiDeclarationSource.php b/app/Enums/AiDeclarationSource.php new file mode 100644 index 00000000..61fecc6d --- /dev/null +++ b/app/Enums/AiDeclarationSource.php @@ -0,0 +1,27 @@ + __('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'), + }; + } +} diff --git a/app/Enums/AiImageDeclaration.php b/app/Enums/AiImageDeclaration.php index 0bfb7ffc..79d7bdac 100644 --- a/app/Enums/AiImageDeclaration.php +++ b/app/Enums/AiImageDeclaration.php @@ -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. * diff --git a/app/Enums/ConfirmationChannel.php b/app/Enums/ConfirmationChannel.php new file mode 100644 index 00000000..8aed3e9e --- /dev/null +++ b/app/Enums/ConfirmationChannel.php @@ -0,0 +1,15 @@ + 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 + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return BelongsTo + */ + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } + + /** + * @return BelongsTo + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function legalText(): BelongsTo + { + return $this->belongsTo(LegalText::class); + } +} diff --git a/app/Models/LegalText.php b/app/Models/LegalText.php new file mode 100644 index 00000000..edbc34b0 --- /dev/null +++ b/app/Models/LegalText.php @@ -0,0 +1,113 @@ + '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 $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; + } +} diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 58364c7c..499e29a7 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -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 */ + 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 + */ + public function rightsConfirmations(): MorphMany + { + return $this->morphMany(ImageRightsConfirmation::class, 'subject'); + } + + /** + * @return MorphOne + */ + 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)) { diff --git a/app/Observers/ImageRightsConfirmationObserver.php b/app/Observers/ImageRightsConfirmationObserver.php new file mode 100644 index 00000000..0d6b67f7 --- /dev/null +++ b/app/Observers/ImageRightsConfirmationObserver.php @@ -0,0 +1,28 @@ +purgingUnpublished) { + return; + } + + throw new LogicException('image_rights_confirmations wird nicht gelöscht; nur nie veröffentlichte Uploads dürfen über purgeForUnpublished() verschwinden.'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 75625dc5..63f4a2f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Services/Image/ImageProvenanceInspector.php b/app/Services/Image/ImageProvenanceInspector.php new file mode 100644 index 00000000..b46d4ef5 --- /dev/null +++ b/app/Services/Image/ImageProvenanceInspector.php @@ -0,0 +1,360 @@ + 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, 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, 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, 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], + ); + } +} diff --git a/app/Services/Image/ImageRightsTextRenderer.php b/app/Services/Image/ImageRightsTextRenderer.php new file mode 100644 index 00000000..64fc3c69 --- /dev/null +++ b/app/Services/Image/ImageRightsTextRenderer.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index d0ac1c8a..8135007e 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -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 $variantSpecs + * @param array{x: int, y: int}|null $focus * @return array */ - 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); diff --git a/app/Services/Image/ProvenanceResult.php b/app/Services/Image/ProvenanceResult.php new file mode 100644 index 00000000..5c84ce60 --- /dev/null +++ b/app/Services/Image/ProvenanceResult.php @@ -0,0 +1,70 @@ +, 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 + */ + 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); + } +} diff --git a/app/Services/PressRelease/Images/ImageSubmission.php b/app/Services/PressRelease/Images/ImageSubmission.php new file mode 100644 index 00000000..76bbcdca --- /dev/null +++ b/app/Services/PressRelease/Images/ImageSubmission.php @@ -0,0 +1,30 @@ +channel !== null; + } +} diff --git a/app/Services/PressRelease/Images/ImageUploadException.php b/app/Services/PressRelease/Images/ImageUploadException.php new file mode 100644 index 00000000..311a41fa --- /dev/null +++ b/app/Services/PressRelease/Images/ImageUploadException.php @@ -0,0 +1,17 @@ +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', + }; + } +} diff --git a/config/images.php b/config/images.php new file mode 100644 index 00000000..66f51322 --- /dev/null +++ b/config/images.php @@ -0,0 +1,83 @@ + (int) env('IMAGE_MAX_BYTES', 16 * 1024 * 1024), + 'max_pixels_per_side' => (int) env('IMAGE_MAX_PIXELS_PER_SIDE', 8000), + + /* + |-------------------------------------------------------------------------- + | Formular v2 (Bildkennzeichnung, Decision-Update 22.09.2026) + |-------------------------------------------------------------------------- + | + | rights_v2: Deploy-Sicherung. Bei false verhält sich alles wie vor der + | Umstellung (altes Formular, alte API-Pflichtfelder). Wartet nicht auf + | den Anwalt (Decision-Update E14). + | + | rules_version: Marker an jedem Bild, das über den neuen Uploader kam. + | NULL = Bestand vor der Umstellung (Legacy-Import oder altes Formular). + | + */ + + 'rights_v2' => (bool) env('IMAGE_RIGHTS_V2', false), + 'rules_version' => '2026-10', + + /* + |-------------------------------------------------------------------------- + | Herkunftsprüfung (A.5) + |-------------------------------------------------------------------------- + | + | provenance_timeout_seconds: Zeitbudget der Metadatenprüfung. Wird es + | überschritten oder wirft die Prüfung, gilt der Status `error` — das + | Formular zeigt dann „konnte nicht geprüft werden", nicht „keine + | Herkunftsdaten" (Feedback F10). + | + */ + + 'provenance_timeout_seconds' => (int) env('IMAGE_PROVENANCE_TIMEOUT', 10), + + /* + |-------------------------------------------------------------------------- + | Bestätigungsprotokoll (A.2) + |-------------------------------------------------------------------------- + | + | confirmation_ip_key: eigener Schlüssel für die HMAC-Ableitung der + | IP-Adresse im Bestätigungsprotokoll. Pseudonymisiert, nicht anonym: Mit + | dem Schlüssel lässt sich prüfen, ob eine Adresse passt, ohne ihn nicht. + | Fällt auf APP_KEY zurück, solange kein eigener gesetzt ist. + | + | confirmation_ip_hash_version: Präfix im gespeicherten Wert, damit ein + | Schlüsselwechsel alte Einträge nicht unbrauchbar macht. + | + */ + + 'confirmation_ip_key' => env('CONFIRMATION_IP_KEY') ?: env('APP_KEY'), + 'confirmation_ip_hash_version' => 'v1', + + /* + |-------------------------------------------------------------------------- + | Ablage (A.6, E.2) + |-------------------------------------------------------------------------- + | + | Beweisoriginal und Snapshot der veröffentlichten Fassung liegen auf der + | privaten Disk, getrennt vom Derivat-Ordner. Sie sind nie über die + | Medienroute erreichbar (E.4). + | + */ + + 'originals_disk' => env('IMAGE_ORIGINALS_DISK', 'local'), + 'originals_directory' => 'press-release-originals', + 'published_snapshot_directory' => 'press-release-published', + +]; diff --git a/database/factories/PressReleaseImageFactory.php b/database/factories/PressReleaseImageFactory.php new file mode 100644 index 00000000..96ba7884 --- /dev/null +++ b/database/factories/PressReleaseImageFactory.php @@ -0,0 +1,86 @@ + + */ +class PressReleaseImageFactory extends Factory +{ + protected $model = PressReleaseImage::class; + + /** + * Standard: Bild aus dem heutigen (alten) Formular — Bestand ohne + * `rules_version`, aber mit Kennzeichnung und Nachweis. + * + * @return array + */ + public function definition(): array + { + $name = Str::uuid()->toString(); + + return [ + 'press_release_id' => PressRelease::factory(), + 'disk' => 'local', + 'path' => "press-releases/1/images/variants/{$name}-cover.jpg", + 'variants' => [ + 'thumb' => "press-releases/1/images/variants/{$name}-thumb.jpg", + 'cover' => "press-releases/1/images/variants/{$name}-cover.jpg", + ], + 'copyright' => fake()->company(), + 'author' => fake()->name(), + 'ai_declaration' => AiImageDeclaration::Original->value, + 'is_preview' => true, + 'sort_order' => 1, + 'width' => 1280, + 'height' => 580, + 'mime' => 'image/jpeg', + 'rights_confirmed_at' => now(), + ]; + } + + /** + * Legacy-Import: kein Nachweis, keine Kennzeichnung, kein Protokoll. + */ + public function legacy(): static + { + return $this->state(fn (): array => [ + 'copyright' => null, + 'author' => null, + 'ai_declaration' => null, + 'rights_confirmed_at' => null, + 'legacy_portal' => 'presseecho', + 'legacy_id' => fake()->unique()->numberBetween(1, 900_000), + 'path' => 'presseecho/press/'.fake()->slug(3).'.jpg', + 'variants' => null, + ]); + } + + /** + * Bild aus Formular v2: Beweisoriginal, Prüfergebnis, Regelversion. + */ + public function rulesV2(): static + { + return $this->state(fn (): array => [ + 'rules_version' => config('images.rules_version'), + 'ai_declaration_user' => AiImageDeclaration::Original->value, + 'ai_declaration_source' => AiDeclarationSource::Customer->value, + 'credit_source' => CreditSource::Customer->value, + 'provenance_status' => ImageProvenanceStatus::Absent->value, + 'provenance' => ['status' => 'absent'], + 'provenance_checked_at' => now(), + 'original_disk' => 'local', + 'original_path' => 'press-release-originals/1/'.Str::uuid()->toString().'.jpg', + 'original_sha256' => hash('sha256', Str::random(40)), + ]); + } +} diff --git a/database/migrations/2026_09_22_100000_add_rights_v2_fields_to_press_release_images_table.php b/database/migrations/2026_09_22_100000_add_rights_v2_fields_to_press_release_images_table.php new file mode 100644 index 00000000..23252fef --- /dev/null +++ b/database/migrations/2026_09_22_100000_add_rights_v2_fields_to_press_release_images_table.php @@ -0,0 +1,78 @@ +string('ai_declaration_user', 20)->nullable()->after('ai_declaration'); + $table->string('ai_declaration_source', 30)->nullable()->after('ai_declaration_user'); + $table->boolean('ai_declaration_locked')->default(false)->after('ai_declaration_source'); + $table->string('credit_source', 20)->nullable()->after('copyright'); + $table->string('provenance_status', 30)->nullable()->after('ai_declaration_locked'); + $table->json('provenance')->nullable()->after('provenance_status'); + $table->timestamp('provenance_checked_at')->nullable()->after('provenance'); + $table->string('provenance_tool_version', 120)->nullable()->after('provenance_checked_at'); + $table->string('original_disk', 30)->nullable()->after('provenance_tool_version'); + $table->string('original_path', 512)->nullable()->after('original_disk'); + $table->char('original_sha256', 64)->nullable()->after('original_path'); + $table->string('published_snapshot_path', 512)->nullable()->after('original_sha256'); + $table->char('published_snapshot_sha256', 64)->nullable()->after('published_snapshot_path'); + $table->timestamp('published_ever_at')->nullable()->after('published_snapshot_sha256'); + $table->timestamp('customer_confirmation_requested_at')->nullable()->after('published_ever_at'); + $table->string('rules_version', 10)->nullable()->after('customer_confirmation_requested_at'); + $table->unsignedTinyInteger('focus_x')->nullable()->after('rules_version'); + $table->unsignedTinyInteger('focus_y')->nullable()->after('focus_x'); + $table->boolean('no_crop')->default(false)->after('focus_y'); + }); + + // Formular validiert seit der Rechtefelder-Migration 2048 Zeichen, + // die Spalte hatte 255 (Feedback F08). + Schema::table('press_release_images', function (Blueprint $table) { + $table->string('license_url', 2048)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('press_release_images', function (Blueprint $table) { + $table->dropColumn([ + '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', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_22_100100_create_legal_texts_table.php b/database/migrations/2026_09_22_100100_create_legal_texts_table.php new file mode 100644 index 00000000..6b004b2a --- /dev/null +++ b/database/migrations/2026_09_22_100100_create_legal_texts_table.php @@ -0,0 +1,44 @@ +id(); + $table->string('key', 40)->unique(); + $table->string('type', 40); + $table->string('locale', 5)->default('de'); + $table->string('title', 160); + $table->text('body'); + $table->text('short_body')->nullable(); + $table->string('bound_agb_version', 10)->nullable(); + $table->char('body_sha256', 64); + $table->date('effective_from')->nullable(); + $table->string('note', 255)->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['type', 'locale', 'bound_agb_version', 'effective_from'], 'legal_texts_lookup_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('legal_texts'); + } +}; diff --git a/database/migrations/2026_09_22_100200_create_image_rights_confirmations_table.php b/database/migrations/2026_09_22_100200_create_image_rights_confirmations_table.php new file mode 100644 index 00000000..7b36e4e3 --- /dev/null +++ b/database/migrations/2026_09_22_100200_create_image_rights_confirmations_table.php @@ -0,0 +1,54 @@ +id(); + $table->string('subject_type', 80); + $table->unsignedBigInteger('subject_id'); + $table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('company_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('personal_access_token_id')->nullable(); + $table->string('channel', 20); + $table->foreignId('legal_text_id')->nullable()->constrained()->nullOnDelete(); + $table->string('legal_text_key', 40); + $table->text('text_snapshot'); + $table->string('agb_version', 10)->nullable(); + $table->string('locale', 5)->default('de'); + $table->char('file_sha256', 64)->nullable(); + $table->json('declaration_snapshot')->nullable(); + $table->string('ip_hmac', 80)->nullable(); + $table->string('user_agent', 255)->nullable(); + $table->timestamp('confirmed_at'); + $table->timestamp('created_at')->nullable(); + + $table->index(['subject_type', 'subject_id', 'confirmed_at'], 'irc_subject_confirmed_idx'); + $table->index(['press_release_id'], 'irc_press_release_idx'); + $table->index(['user_id', 'confirmed_at'], 'irc_user_confirmed_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('image_rights_confirmations'); + } +}; diff --git a/database/seeders/LegalTextSeeder.php b/database/seeders/LegalTextSeeder.php new file mode 100644 index 00000000..73cea337 --- /dev/null +++ b/database/seeders/LegalTextSeeder.php @@ -0,0 +1,77 @@ +create($text + ['created_at' => now()]); + } + } + + /** + * @return list> + */ + public static function texts(): array + { + $long = 'Ich bestätige, dass ich zur Veröffentlichung dieses Bildes auf {portals} sowie im Rahmen der von mir gebuchten Verbreitung berechtigt bin. Das umfasst die Rechte des Urhebers, die Rechte abgebildeter Personen sowie Marken- und sonstige Rechte Dritter. Mein Bildnachweis und meine Angabe zur KI-Kennzeichnung sind zutreffend. Für diese Einreichung gelten die Allgemeinen Geschäftsbedingungen in der Fassung vom {agb_date} (Version {agb_version}, {agb_url}), die ich hiermit anerkenne. Die Regeln zu Bildern stehen dort in %s; bei schuldhafter Verletzung meiner vertraglichen Pflichten gelten die Freistellungsregelungen in %s.'; + + $short = 'Ich bestätige, dass ich zur Veröffentlichung dieses Bildes berechtigt bin, meine Angaben zutreffen und die AGB (Fassung vom {agb_date}) gelten.'; + + return [ + [ + 'key' => 'img-confirm-v1', + 'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, + 'locale' => 'de', + 'title' => 'Rechte bestätigt', + 'body' => 'Ich bestätige, dass ich über die erforderlichen Rechte zur Veröffentlichung dieses Bildes verfüge und die Verantwortung für die Richtigkeit meiner Angaben übernehme. Dies umfasst Urheberrechte, Nutzungsrechte, Persönlichkeitsrechte abgebildeter Personen sowie gegebenenfalls Marken-, Eigentums- oder sonstige Rechte Dritter. Mir ist bewusst, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin.', + 'short_body' => null, + 'bound_agb_version' => '0.6', + 'effective_from' => '2026-05-29', + 'note' => 'Wortlaut des alten Bildmanagers (Lizenzfelder-Formular), rückwirkend archiviert', + ], + [ + 'key' => 'img-confirm-v2', + 'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, + 'locale' => 'de', + 'title' => 'Bestätigung der Bildrechte', + 'body' => sprintf($long, '§ 8 und § 9', '§ 8 Abs. 2'), + 'short_body' => $short, + 'bound_agb_version' => '0.6', + 'effective_from' => '2026-09-22', + 'note' => 'Formular v2 (Decision-Update 22.09.2026), Verweis auf AGB 0.6; Anwaltsprüfung ausstehend', + ], + [ + 'key' => 'img-confirm-v3', + 'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, + 'locale' => 'de', + 'title' => 'Bestätigung der Bildrechte', + 'body' => sprintf($long, '§ 8, § 8a und § 9', '§ 8 Abs. 2 und § 8a Abs. 5'), + 'short_body' => $short, + 'bound_agb_version' => '0.7', + 'effective_from' => null, + 'note' => 'Formular v2 mit § 8a, gebunden an AGB 0.7; wird mit Deploy 3 wirksam', + ], + ]; + } +} diff --git a/tests/Feature/Images/ImageProvenanceInspectorTest.php b/tests/Feature/Images/ImageProvenanceInspectorTest.php new file mode 100644 index 00000000..98609dcb --- /dev/null +++ b/tests/Feature/Images/ImageProvenanceInspectorTest.php @@ -0,0 +1,77 @@ +inspect(provenanceFixture('camera-iptc.jpg')); + + expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata) + ->and($result->creditSuggestion())->toBe('Musterfirma GmbH / Anna Beispiel') + ->and($result->derived['credit_suggestion_source'])->toBe('credit') + ->and($result->suggestedAiDeclaration())->toBeNull() + ->and($result->enforcedAiDeclaration())->toBeNull() + ->and($result->iptc['byline'])->toBe('Anna Beispiel') + ->and($result->iptc['copyright_notice'])->toBe('© 2026 Musterfirma GmbH') + ->and($result->fileSha256)->toBe(hash_file('sha256', provenanceFixture('camera-iptc.jpg'))); +}); + +test('xmp trainedAlgorithmicMedia as full iptc uri suggests ai_generated but never locks in phase one', function () { + $result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-ai-generated.jpg')); + + expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata) + ->and($result->iptc['digital_source_type'])->toBe('trainedAlgorithmicMedia') + ->and($result->suggestedAiDeclaration()?->value)->toBe('ai_generated') + ->and($result->enforcedAiDeclaration())->toBeNull() + ->and($result->creditSuggestion())->toBe('KI-Bild / Musterfirma'); +}); + +test('algorithmicallyEnhanced only sets the enhancement hint and takes the creator list as credit', function () { + $result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-enhanced.jpg')); + + expect($result->suggestedAiDeclaration())->toBeNull() + ->and($result->derived['enhancement_hint'])->toBeTrue() + ->and($result->creditSuggestion())->toBe('Max Mustermann') + ->and($result->derived['credit_suggestion_source'])->toBe('creator'); +}); + +test('a png without metadata is absent, not an error', function () { + $result = (new ImageProvenanceInspector)->inspect(provenanceFixture('plain.png')); + + expect($result->status)->toBe(ImageProvenanceStatus::Absent) + ->and($result->creditSuggestion())->toBeNull() + ->and($result->error)->toBeNull(); +}); + +test('webp xmp digitalCapture suggests original and falls back to the copyright notice for the credit', function () { + $result = (new ImageProvenanceInspector)->inspect(provenanceFixture('webp-capture.webp')); + + expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata) + ->and($result->suggestedAiDeclaration()?->value)->toBe('original') + ->and($result->creditSuggestion())->toBe('© Beispiel AG') + ->and($result->derived['credit_suggestion_source'])->toBe('copyright_notice'); +}); + +test('an unreadable file reports error instead of pretending there is no metadata', function () { + $result = (new ImageProvenanceInspector)->inspect(provenanceFixture('broken.jpg')); + + expect($result->status)->toBe(ImageProvenanceStatus::Error) + ->and($result->error)->not->toBeNull() + ->and($result->toArray()['error']['class'])->toBe('ImagickException'); +}); + +test('the result serialises with status, tool and duration for the image row', function () { + $array = (new ImageProvenanceInspector)->inspect(provenanceFixture('camera-iptc.jpg'))->toArray(); + + expect($array)->toHaveKeys(['status', 'file_sha256', 'tool', 'duration_ms', 'iptc', 'exif', 'derived']) + ->and($array['tool'])->toStartWith('imagick '); +}); diff --git a/tests/Feature/Images/ImageRightsConfirmationTest.php b/tests/Feature/Images/ImageRightsConfirmationTest.php new file mode 100644 index 00000000..e103f42e --- /dev/null +++ b/tests/Feature/Images/ImageRightsConfirmationTest.php @@ -0,0 +1,101 @@ +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_key' => 'img-confirm-v2', + 'text_snapshot' => 'Ich bestätige …', + 'agb_version' => '0.6', + 'file_sha256' => $image->original_sha256, + 'declaration_snapshot' => ['copyright' => $image->copyright], + 'ip_hmac' => ImageRightsConfirmation::hashIp('203.0.113.7'), + 'user_agent' => 'PestBrowser/1.0', + 'confirmed_at' => now(), + 'created_at' => now(), + ]); +} + +test('confirmations are insert-only', function () { + $image = PressReleaseImage::factory()->rulesV2()->create(); + $confirmation = makeConfirmationFor($image); + + expect(fn () => $confirmation->update(['agb_version' => '0.7']))->toThrow(LogicException::class) + ->and(fn () => $confirmation->delete())->toThrow(LogicException::class) + ->and(ImageRightsConfirmation::query()->count())->toBe(1); +}); + +test('only the purge for never published uploads may remove a confirmation', function () { + $image = PressReleaseImage::factory()->rulesV2()->create(); + $confirmation = makeConfirmationFor($image); + + $confirmation->purgeForUnpublished(); + + expect(ImageRightsConfirmation::query()->count())->toBe(0); +}); + +test('a hard deleted image leaves its confirmation with the snapshot in place', function () { + $image = PressReleaseImage::factory()->rulesV2()->create(); + $confirmation = makeConfirmationFor($image); + $pressReleaseId = $image->press_release_id; + + $image->forceDelete(); + + $confirmation->refresh(); + + expect($confirmation->subject_id)->toBe($image->getKey()) + ->and($confirmation->press_release_id)->toBe($pressReleaseId) + ->and($confirmation->file_sha256)->toBe($image->original_sha256) + ->and($confirmation->declaration_snapshot['copyright'])->toBe($image->copyright); +}); + +test('a hard deleted press release nulls the reference but keeps the row', function () { + $pressRelease = PressRelease::factory()->create(); + $image = PressReleaseImage::factory()->for($pressRelease)->rulesV2()->create(); + $confirmation = makeConfirmationFor($image); + + $pressRelease->forceDelete(); + + expect($confirmation->fresh())->not->toBeNull() + ->and($confirmation->fresh()?->press_release_id)->toBeNull(); +}); + +test('the ip derivation is keyed, versioned and not a plain hash', function () { + config()->set('images.confirmation_ip_key', 'geheim-1'); + $first = ImageRightsConfirmation::hashIp('203.0.113.7'); + + config()->set('images.confirmation_ip_key', 'geheim-2'); + $second = ImageRightsConfirmation::hashIp('203.0.113.7'); + + expect($first)->toStartWith('v1:') + ->and($first)->not->toBe($second) + ->and($first)->not->toContain(hash('sha256', '203.0.113.7')) + ->and(ImageRightsConfirmation::hashIp(null))->toBeNull(); +}); + +test('the image exposes its latest valid confirmation bound to the current file hash', function () { + $image = PressReleaseImage::factory()->rulesV2()->create(); + + expect($image->hasValidRightsConfirmation())->toBeFalse() + ->and($image->isLegacyRules())->toBeFalse(); + + makeConfirmationFor($image); + + expect($image->fresh()->hasValidRightsConfirmation())->toBeTrue(); + + // Bildtausch: neuer Hash, alte Bestätigung zählt nicht mehr (F09). + $image->update(['original_sha256' => hash('sha256', 'neue-datei')]); + + expect($image->fresh()->hasValidRightsConfirmation())->toBeFalse() + ->and(PressReleaseImage::factory()->legacy()->create()->isLegacyRules())->toBeTrue(); +}); diff --git a/tests/Feature/Images/PressReleaseImageUploaderTest.php b/tests/Feature/Images/PressReleaseImageUploaderTest.php new file mode 100644 index 00000000..5287334b --- /dev/null +++ b/tests/Feature/Images/PressReleaseImageUploaderTest.php @@ -0,0 +1,257 @@ +seed(LegalDocumentSeeder::class); + $this->seed(LegalTextSeeder::class); +}); + +function uploaderFixture(string $name): UploadedFile +{ + $source = base_path('tests/Fixtures/images/'.$name); + $copy = tempnam(sys_get_temp_dir(), 'pk-upload-'); + copy($source, $copy); + + return new UploadedFile($copy, $name, mime_content_type($copy), null, true); +} + +function uploaderSubmission(array $overrides = []): ImageSubmission +{ + return new ImageSubmission(...array_merge([ + 'copyright' => 'Musterfirma GmbH', + 'aiDeclaration' => AiImageDeclaration::Original, + 'rightsConfirmed' => true, + 'legalTextKey' => 'img-confirm-v2', + ], $overrides)); +} + +function uploaderActor(PressRelease $pressRelease): ImageUploadActor +{ + return ImageUploadActor::form($pressRelease->user, '203.0.113.9', 'PestBrowser/1.0'); +} + +test('a form upload stores original, derivatives, provenance and a confirmation bound to the same hash', function () { + $pressRelease = PressRelease::factory()->create(); + $upload = uploaderFixture('camera-iptc.jpg'); + $expectedHash = hash_file('sha256', $upload->getRealPath()); + + $image = app(PressReleaseImageUploader::class)->store( + $upload, + $pressRelease, + uploaderSubmission(['creditFromSuggestion' => true]), + uploaderActor($pressRelease), + expectedSha256: $expectedHash, + agbUrl: 'https://presseecho.test/de/agb', + ); + + expect($image->rules_version)->toBe(config('images.rules_version')) + ->and($image->original_sha256)->toBe($expectedHash) + ->and($image->original_path)->toStartWith('press-release-originals/'.$pressRelease->id.'/') + ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() + ->and(Storage::disk('local')->exists($image->path))->toBeTrue() + ->and($image->variants)->toHaveKeys(['thumb', 'medium', 'large', 'cover', 'og']) + ->and($image->path)->not->toBe($image->original_path) + ->and($image->provenance_status)->toBe(ImageProvenanceStatus::UnsignedMetadata) + ->and($image->provenance['derived']['credit_suggestion'])->toBe('Musterfirma GmbH / Anna Beispiel') + ->and($image->credit_source?->value)->toBe('metadata') + ->and($image->ai_declaration)->toBe(AiImageDeclaration::Original) + ->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original) + ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer) + ->and($image->ai_declaration_locked)->toBeFalse() + ->and($image->rights_confirmed_at)->not->toBeNull() + ->and($image->published_ever_at)->toBeNull() + ->and($image->is_preview)->toBeTrue(); + + $confirmation = ImageRightsConfirmation::query()->sole(); + + expect($confirmation->file_sha256)->toBe($expectedHash) + ->and($confirmation->legal_text_key)->toBe('img-confirm-v2') + ->and($confirmation->agb_version)->toBe('0.6') + ->and($confirmation->user_id)->toBe($pressRelease->user_id) + ->and($confirmation->company_id)->toBe($pressRelease->company_id) + ->and($confirmation->channel->value)->toBe('form') + ->and($confirmation->text_snapshot)->toContain('https://presseecho.test/de/agb') + ->and($confirmation->ip_hmac)->toStartWith('v1:') + ->and($confirmation->declaration_snapshot['provenance_status'])->toBe('unsigned_metadata') + ->and($image->fresh()->hasValidRightsConfirmation())->toBeTrue(); +}); + +test('unsigned ai metadata only prefills: a lower user choice is stored as customer choice with a deviation mark', function () { + $pressRelease = PressRelease::factory()->create(); + + $image = app(PressReleaseImageUploader::class)->store( + uploaderFixture('xmp-ai-generated.jpg'), + $pressRelease, + uploaderSubmission(['aiDeclaration' => AiImageDeclaration::Original]), + uploaderActor($pressRelease), + ); + + expect($image->ai_declaration)->toBe(AiImageDeclaration::Original) + ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer) + ->and($image->ai_declaration_locked)->toBeFalse() + ->and($image->provenance['deviated_from_prefill'])->toBeTrue() + ->and($image->provenance['derived']['ai_declaration_suggestion'])->toBe('ai_generated'); +}); + +test('accepting the prefilled ai suggestion is recorded as iptc_prefill', function () { + $pressRelease = PressRelease::factory()->create(); + + $image = app(PressReleaseImageUploader::class)->store( + uploaderFixture('xmp-ai-generated.jpg'), + $pressRelease, + uploaderSubmission(['aiDeclaration' => AiImageDeclaration::AiGenerated]), + uploaderActor($pressRelease), + ); + + expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated) + ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::IptcPrefill) + ->and($image->provenance)->not->toHaveKey('deviated_from_prefill'); +}); + +test('a changed file between preflight and store is rejected and leaves no files behind', function () { + $pressRelease = PressRelease::factory()->create(); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + expectedSha256: hash('sha256', 'andere-datei'), + ))->toThrow(ImageUploadException::class); + + expect(Storage::disk('local')->allFiles())->toBe([]) + ->and($pressRelease->images()->count())->toBe(0); +}); + +test('a database failure after the files were written removes original and derivatives again', function () { + $pressRelease = PressRelease::factory()->create(); + + DB::listen(function ($query): void { + if (str_starts_with(strtolower($query->sql), 'insert into "press_release_images"')) { + throw new RuntimeException('simulierter DB-Fehler'); + } + }); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + ))->toThrow(RuntimeException::class, 'simulierter DB-Fehler'); + + expect(Storage::disk('local')->allFiles())->toBe([]) + ->and(ImageRightsConfirmation::query()->count())->toBe(0); +}); + +test('the one title image rule holds inside the locked transaction', function () { + $pressRelease = PressRelease::factory()->create(); + $uploader = app(PressReleaseImageUploader::class); + + $uploader->store(uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease)); + + expect(fn () => $uploader->store(uploaderFixture('camera-iptc.jpg'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease))) + ->toThrow(ImageUploadException::class); + + expect($pressRelease->images()->count())->toBe(1) + ->and(count(Storage::disk('local')->allFiles('press-release-originals')))->toBe(1); +}); + +test('without the confirmation toggle nothing is stored', function () { + $pressRelease = PressRelease::factory()->create(); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(['rightsConfirmed' => false]), + uploaderActor($pressRelease), + ))->toThrow(ImageUploadException::class); + + expect(Storage::disk('local')->allFiles())->toBe([]); +}); + +test('an outdated confirmation text key is rejected with the current key in the message', function () { + $pressRelease = PressRelease::factory()->create(); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(['legalTextKey' => 'img-confirm-v1']), + uploaderActor($pressRelease), + ))->toThrow(ImageUploadException::class, 'img-confirm-v2'); +}); + +test('an admin upload stores no confirmation and waits for the customer', function () { + $pressRelease = PressRelease::factory()->create(); + $admin = User::factory()->create(); + + $image = app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(['rightsConfirmed' => false, 'legalTextKey' => null]), + ImageUploadActor::admin($admin, '203.0.113.1', 'AdminBrowser'), + ); + + expect(ImageRightsConfirmation::query()->count())->toBe(0) + ->and($image->rights_confirmed_at)->toBeNull() + ->and($image->customer_confirmation_requested_at)->not->toBeNull() + ->and($image->awaitsCustomerConfirmation())->toBeTrue(); +}); + +test('oversized uploads are refused before any processing', function () { + $pressRelease = PressRelease::factory()->create(); + config()->set('images.max_pixels_per_side', 32); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + uploaderFixture('camera-iptc.jpg'), // 64 × 48 px + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + ))->toThrow(ImageUploadException::class, 'Pixel'); + + expect(Storage::disk('local')->allFiles())->toBe([]); +}); + +test('an upload to an already published press release is marked as ever published', function () { + $pressRelease = PressRelease::factory()->published()->create(); + + $image = app(PressReleaseImageUploader::class)->store( + uploaderFixture('plain.png'), + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + ); + + expect($image->published_ever_at)->not->toBeNull(); +}); + +test('a failed derivative generation never leaves the source file public', function () { + $pressRelease = PressRelease::factory()->create(); + $upload = uploaderFixture('broken.jpg'); + + expect(fn () => app(PressReleaseImageUploader::class)->store( + $upload, + $pressRelease, + uploaderSubmission(), + uploaderActor($pressRelease), + ))->toThrow(ImageUploadException::class); + + expect(Storage::disk('local')->allFiles())->toBe([]); +}); diff --git a/tests/Feature/Legal/LegalTextTest.php b/tests/Feature/Legal/LegalTextTest.php new file mode 100644 index 00000000..891716d3 --- /dev/null +++ b/tests/Feature/Legal/LegalTextTest.php @@ -0,0 +1,93 @@ +seed(LegalTextSeeder::class); + $this->seed(LegalTextSeeder::class); + + expect(LegalText::query()->count())->toBe(3); + + $v2 = LegalText::findByKey('img-confirm-v2'); + expect($v2?->body_sha256)->toBe(hash('sha256', (string) $v2?->body)) + ->and($v2?->bound_agb_version)->toBe('0.6') + ->and(LegalText::findByKey('img-confirm-v3')?->effective_from)->toBeNull(); +}); + +test('currentFor picks the text bound to the active agb version and ignores fassungen without a date', function () { + /** @var TestCase $this */ + $this->seed(LegalTextSeeder::class); + + expect(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.6')?->key)->toBe('img-confirm-v2') + ->and(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.7'))->toBeNull() + ->and(LegalText::findByKey('img-confirm-v2')?->isCurrentFor('0.6'))->toBeTrue() + ->and(LegalText::findByKey('img-confirm-v1')?->isCurrentFor('0.6'))->toBeFalse(); +}); + +test('legal texts are immutable', function () { + /** @var TestCase $this */ + $this->seed(LegalTextSeeder::class); + $text = LegalText::findByKey('img-confirm-v2'); + + expect(fn () => $text->update(['body' => 'geändert']))->toThrow(LogicException::class) + ->and(fn () => $text->delete())->toThrow(LogicException::class); +}); + +test('the renderer resolves agb version, date and url into the long and short wording', function () { + /** @var TestCase $this */ + $this->seed(LegalDocumentSeeder::class); + $this->seed(LegalTextSeeder::class); + + $renderer = app(ImageRightsTextRenderer::class); + $text = $renderer->current(); + + expect($text?->key)->toBe('img-confirm-v2'); + + $long = $renderer->renderLong($text, 'https://presseecho.test/de/agb'); + $short = $renderer->renderShort($text, 'https://presseecho.test/de/agb'); + + expect($long)->toContain('Fassung vom 3. Juli 2026 (Version 0.6, https://presseecho.test/de/agb)') + ->toContain('presseecho.de und businessportal24.com') + ->toContain('§ 8 und § 9') + ->not->toContain('{agb_version}') + ->and($short)->toContain('Fassung vom 3. Juli 2026') + ->and(LegalDocument::currentAgbVersion())->toBe('0.6'); +}); + +test('the backfill command gives every old confirmation a protocol row pointing at img-confirm-v1', function () { + /** @var TestCase $this */ + $this->seed(LegalTextSeeder::class); + + $pressRelease = PressRelease::factory()->create(); + $confirmed = PressReleaseImage::factory()->for($pressRelease)->create(['rights_confirmed_at' => now()->subMonth()]); + PressReleaseImage::factory()->for($pressRelease)->legacy()->create(); + + $this->artisan('image-rights:backfill-confirmations', ['--dry-run' => true]) + ->expectsOutputToContain('Bilder mit alter Bestätigung ohne Protokoll: 1') + ->assertSuccessful(); + + expect(ImageRightsConfirmation::query()->count())->toBe(0); + + $this->artisan('image-rights:backfill-confirmations')->assertSuccessful(); + $this->artisan('image-rights:backfill-confirmations')->assertSuccessful(); + + $confirmation = $confirmed->rightsConfirmations()->sole(); + + expect(ImageRightsConfirmation::query()->count())->toBe(1) + ->and($confirmation->legal_text_key)->toBe('img-confirm-v1') + ->and($confirmation->agb_version)->toBe('0.6') + ->and($confirmation->user_id)->toBe($pressRelease->user_id) + ->and($confirmation->company_id)->toBe($pressRelease->company_id) + ->and($confirmation->confirmed_at->toDateTimeString())->toBe($confirmed->rights_confirmed_at->toDateTimeString()) + ->and($confirmation->declaration_snapshot['backfilled'])->toBeTrue() + ->and($confirmation->text_snapshot)->toBe(LegalText::findByKey('img-confirm-v1')?->body); +}); diff --git a/tests/Fixtures/images/broken.jpg b/tests/Fixtures/images/broken.jpg new file mode 100644 index 00000000..980bd115 --- /dev/null +++ b/tests/Fixtures/images/broken.jpg @@ -0,0 +1,4 @@ +ÿØÿàðiÁ?œžò‚¼~ +šʍæÿׯBŸTƒÛà¯Ɲ÷§�r‡@·¦å)Jb2÷™¸9Ý\¥X•�¶4þ�ÆX�ÎáAұç¾Qý#÷S“>9ÿbáÓÑ3Íʒ¡ãöâm@Ү'T@2å!IBÃb%‡Ù]Ô@Šý]‰CE^rš†; +֟œ�æýÐ \ No newline at end of file diff --git a/tests/Fixtures/images/camera-iptc.jpg b/tests/Fixtures/images/camera-iptc.jpg new file mode 100644 index 00000000..7e447cf4 Binary files /dev/null and b/tests/Fixtures/images/camera-iptc.jpg differ diff --git a/tests/Fixtures/images/generate-fixtures.php b/tests/Fixtures/images/generate-fixtures.php new file mode 100644 index 00000000..3460df40 --- /dev/null +++ b/tests/Fixtures/images/generate-fixtures.php @@ -0,0 +1,85 @@ + $values) { + foreach ((array) $values as $value) { + $block .= "\x1C\x02".chr($dataset).pack('n', strlen($value)).$value; + } + } + + return $block; +} + +function xmp(array $description): string +{ + return '' + .'' + .'' + .implode('', $description) + .''; +} + +function make(string $file, string $format, ?string $iptc = null, ?string $xmp = null): void +{ + $image = new Imagick; + $image->newImage(64, 48, new ImagickPixel('#4a6fa5'), $format); + $image->setImageFormat($format); + + if ($iptc !== null) { + $image->setImageProfile('iptc', $iptc); + } + + if ($xmp !== null) { + $image->setImageProfile('xmp', $xmp); + } + + $image->writeImage($file); + $image->clear(); + echo "written: {$file}\n"; +} + +// 1. Kamera-JPEG mit klassischem IPTC (Credit, By-line, Copyright). +make("{$dir}/camera-iptc.jpg", 'jpeg', iptcBlock([ + 110 => 'Musterfirma GmbH / Anna Beispiel', + 80 => 'Anna Beispiel', + 116 => '© 2026 Musterfirma GmbH', +])); + +// 2. JPEG mit XMP: KI-generiert (volle IPTC-URI) und Credit als Element. +make("{$dir}/xmp-ai-generated.jpg", 'jpeg', null, xmp([ + 'http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia', + 'KI-Bild / Musterfirma', +])); + +// 3. JPEG mit XMP algorithmicallyEnhanced (Kurzform) und dc:creator-Liste. +make("{$dir}/xmp-enhanced.jpg", 'jpeg', null, xmp([ + 'algorithmicallyEnhanced', + 'Max Mustermann', +])); + +// 4. PNG ohne Metadaten. +make("{$dir}/plain.png", 'png'); + +// 5. WebP mit XMP als Attribute (digitalCapture, dc:rights). +make("{$dir}/webp-capture.webp", 'webp', null, xmp([ + 'digitalCapture', + '© Beispiel AG', +])); + +// 6. Beschädigte Datei: JPEG-Header, dann Müll. +file_put_contents("{$dir}/broken.jpg", "\xFF\xD8\xFF\xE0".random_bytes(200)); +echo "written: {$dir}/broken.jpg\n"; diff --git a/tests/Fixtures/images/plain.png b/tests/Fixtures/images/plain.png new file mode 100644 index 00000000..e1d642f1 Binary files /dev/null and b/tests/Fixtures/images/plain.png differ diff --git a/tests/Fixtures/images/webp-capture.webp b/tests/Fixtures/images/webp-capture.webp new file mode 100644 index 00000000..ce40c05f Binary files /dev/null and b/tests/Fixtures/images/webp-capture.webp differ diff --git a/tests/Fixtures/images/xmp-ai-generated.jpg b/tests/Fixtures/images/xmp-ai-generated.jpg new file mode 100644 index 00000000..84f59a49 Binary files /dev/null and b/tests/Fixtures/images/xmp-ai-generated.jpg differ diff --git a/tests/Fixtures/images/xmp-enhanced.jpg b/tests/Fixtures/images/xmp-enhanced.jpg new file mode 100644 index 00000000..49101854 Binary files /dev/null and b/tests/Fixtures/images/xmp-enhanced.jpg differ