- 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>
370 lines
14 KiB
PHP
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],
|
|
);
|
|
}
|
|
}
|