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