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:
Kevin Adametz 2026-09-22 09:22:05 +00:00
parent 83cb6e14cd
commit 214aea8910
36 changed files with 2631 additions and 30 deletions

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

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

View file

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

View 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';
}

View 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';
}

View 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);
}
}

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

View file

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

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

View file

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

View 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],
);
}
}

View 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);
}
}

View file

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

View 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);
}
}

View 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,
) {}
}

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

View 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);
}
}

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

83
config/images.php Normal file
View file

@ -0,0 +1,83 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Bild-Uploads: Grenzen (Bildkennzeichnung E-B6)
|--------------------------------------------------------------------------
|
| Formular und API teilen sich dieselben Grenzen. Beide greifen VOR der
| Herkunftsprüfung und der Variantenerzeugung, damit präparierte Dateien
| keine teure Verarbeitung auslösen.
|
*/
'max_bytes' => (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',
];

View file

@ -0,0 +1,86 @@
<?php
namespace Database\Factories;
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\CreditSource;
use App\Enums\ImageProvenanceStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<PressReleaseImage>
*/
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<string, mixed>
*/
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)),
]);
}
}

View file

@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bildkennzeichnung A.1 (Decision-Update 22.09.2026): Formular v2,
* Herkunftsprüfung, Beweisoriginal, veröffentlichte Fassung, Bestandsmarker.
*
* Alles nullable, der Bestand bleibt unangetastet (E1, Feedback F01):
* `ai_declaration` bleibt die öffentliche Portalkennzeichnung, an der alle
* Leser hängen; die Kundenangabe kommt getrennt in `ai_declaration_user`.
* `rules_version` NULL = Bestand (Legacy-Import oder altes Formular).
*
* press_release_images hat keinen FULLTEXT-Index — ADD COLUMN läuft inplace
* (siehe CLAUDE.md, Schema changes).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('press_release_images', function (Blueprint $table) {
$table->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',
]);
});
}
};

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Unveränderliches Archiv der Bestätigungstexte (Bildkennzeichnung A.3,
* Feedback F06): Jede Bestätigung verweist auf genau eine Fassung, deren
* Wortlaut hier byte-genau liegt. Kennungen (`key`) werden nie
* wiederverwendet; eine neue Fassung ist eine neue Zeile.
*
* Der Wortlaut ist an eine AGB-Fassung gebunden (`bound_agb_version`), weil
* er die Paragrafen nennt (Decision-Update 3.3). Platzhalter im Text
* ({agb_version}, {agb_date}, {agb_url}) werden beim Rendern aufgelöst und
* mit der Bestätigung als Snapshot gespeichert.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('legal_texts', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bestätigungsprotokoll je Bild (Bildkennzeichnung A.2, Decision-Update S2):
* wer (Kontoinhaber = Vertragspartner, E1) wann über welchen Kanal welchen
* Wortlaut zu welcher Datei bestätigt hat. Insert-only; der Observer wirft
* bei Update oder Delete. Einzige Ausnahme: Sofortlöschung nie
* veröffentlichter Uploads (E4, Block E.3).
*
* Polymorph (`subject`), damit Newsroom-Bilder (Roadmap 1b.3) dasselbe
* Protokoll nutzen (E-B12). Fremdschlüssel auf Bild und PM mit nullOnDelete;
* die Snapshot-Spalten tragen die Werte weiter, wenn das Bild hart gelöscht
* wird (E-B10).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('image_rights_confirmations', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,77 @@
<?php
namespace Database\Seeders;
use App\Models\LegalText;
use Illuminate\Database\Seeder;
/**
* Bestätigungstexte für den Bild-Upload (Bildkennzeichnung A.3,
* Decision-Update 3.3). Kennungen werden nie wiederverwendet; der Seeder
* legt fehlende Fassungen an und lässt vorhandene unverändert.
*
* - img-confirm-v1: der Wortlaut des alten Formulars (seit 29.05.2026),
* rückwirkend archiviert für die bestehenden Bestätigungen.
* - img-confirm-v2: Formular v2, gebunden an AGB 0.6 (Verweis § 8, § 9).
* - img-confirm-v3: derselbe Text mit § 8a, gebunden an AGB 0.7 — wird
* erst mit Deploy 3 wirksam (effective_from bleibt leer).
*/
class LegalTextSeeder extends Seeder
{
public function run(): void
{
foreach (self::texts() as $text) {
if (LegalText::findByKey($text['key']) !== null) {
continue;
}
LegalText::query()->create($text + ['created_at' => now()]);
}
}
/**
* @return list<array<string, string|null>>
*/
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',
],
];
}
}

View file

@ -0,0 +1,77 @@
<?php
use App\Enums\ImageProvenanceStatus;
use App\Services\Image\ImageProvenanceInspector;
/**
* Bildkennzeichnung A.5: echte Fixture-Dateien (tests/Fixtures/images,
* erzeugt mit generate-fixtures.php), keine Fake-Bilder.
*/
function provenanceFixture(string $name): string
{
return base_path('tests/Fixtures/images/'.$name);
}
test('a camera jpeg with classic iptc yields an unsigned credit suggestion and no ai suggestion', function () {
$result = (new ImageProvenanceInspector)->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 ');
});

View file

@ -0,0 +1,101 @@
<?php
use App\Enums\ConfirmationChannel;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
function makeConfirmationFor(PressReleaseImage $image): ImageRightsConfirmation
{
return ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $image->press_release_id,
'company_id' => $image->pressRelease->company_id,
'user_id' => $image->pressRelease->user_id,
'channel' => ConfirmationChannel::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();
});

View file

@ -0,0 +1,257 @@
<?php
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
Storage::fake('local');
$this->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([]);
});

View file

@ -0,0 +1,93 @@
<?php
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\LegalText;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageRightsTextRenderer;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Tests\TestCase;
test('the seeder registers the three confirmation texts idempotently and never rewrites one', function () {
/** @var TestCase $this */
$this->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);
});

View file

@ -0,0 +1,4 @@
ЪьЪЮПiц│?°·Р┌╪~
 й█ФЪвфB÷Н╡┘T┐шЮ╞ф²В╖│r┤@╥╕Е)Jb2В≥╦9щ\╔X∙│╤4Ч░фX█нАAр╠Г╬QЩ#ВS⌠>9Ъб█bАся3<LвВ·лэOл╟о0╬ДП}eqхсЛрgцц}┤
ё{У╣Jьyф°уM╥* а≈{Г┌>мй▓║ЦЖБm@р╝'T@2Е!IBцb%┤ы]т@┼Щ]┴CE^r ├;
ж÷°█ФЩп

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

View file

@ -0,0 +1,85 @@
<?php
/**
* Erzeugt die Fixture-Dateien für die Herkunftsprüfung (Bildkennzeichnung
* A.5) einmalig mit Imagick. Aufruf: php tests/Fixtures/images/generate-fixtures.php
*
* Die Dateien werden eingecheckt; das Skript dient nur der Nachvollziehbarkeit.
*/
declare(strict_types=1);
$dir = __DIR__;
function iptcBlock(array $datasets): string
{
// IPTC-IIM: 0x1C, Record 2, Dataset, Länge (16 Bit), Daten.
$block = '';
foreach ($datasets as $dataset => $values) {
foreach ((array) $values as $value) {
$block .= "\x1C\x02".chr($dataset).pack('n', strlen($value)).$value;
}
}
return $block;
}
function xmp(array $description): string
{
return '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
.'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
.'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/" xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">'
.implode('', $description)
.'</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
}
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([
'<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia</Iptc4xmpExt:DigitalSourceType>',
'<photoshop:Credit>KI-Bild / Musterfirma</photoshop:Credit>',
]));
// 3. JPEG mit XMP algorithmicallyEnhanced (Kurzform) und dc:creator-Liste.
make("{$dir}/xmp-enhanced.jpg", 'jpeg', null, xmp([
'<Iptc4xmpExt:DigitalSourceType>algorithmicallyEnhanced</Iptc4xmpExt:DigitalSourceType>',
'<dc:creator><rdf:Seq><rdf:li>Max Mustermann</rdf:li></rdf:Seq></dc:creator>',
]));
// 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([
'<Iptc4xmpExt:DigitalSourceType>digitalCapture</Iptc4xmpExt:DigitalSourceType>',
'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">© Beispiel AG</rdf:li></rdf:Alt></dc:rights>',
]));
// 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";

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B