presseportale/app/Services/Image/ImageProvenanceInspector.php
Kevin Adametz f1c0e2766b images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit
  isPubliclyDeliverable()
- B-03: Livewire-Temp-Upload 16 MiB
- N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put()
  geprüft, alle Fassungen ins Archiv
- O03: published_ever_at in der Freigabetransaktion
  (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur
- B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre
- B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag;
  Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert
- B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme)
- B-08, B-09, B-11, B-12: published_ever_at nur bei Status published,
  Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale,
  Verarbeitungsfehler als Formularmeldung
- K-02-Teil: nach Kundenbestätigung erneut routen
- OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame

Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08
(Nachweis per POST), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

370 lines
14 KiB
PHP

<?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 mit Imagick und liest die eingebetteten IPTC- und
* XMP-Profile. Wirft, wenn die Datei kein lesbares Bild ist.
*
* OPT-3 (Umsetzungsaudit 29.09.): JPEG nur „anpingen" (Kopf und
* Profile ohne Pixeldekodierung). Die PNG- und WebP-Coder liefern beim
* Ping kein XMP (mit Fixtures geprüft) — dort weiter voll lesen.
*
* @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->pingImage($absolutePath);
if (strtoupper($imagick->getImageFormat()) !== 'JPEG') {
$imagick->clear();
$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],
);
}
}