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