legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)

- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:12:17 +00:00
parent b5b70416f2
commit 075d0d931f
27 changed files with 866 additions and 15 deletions

View file

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Zustimmungsgate per API (Block G, O2): einmal je Kontoinhaber und
* Dokumentversion. Ohne gültige Annahme antwortet POST …/submit mit 428
* und den geforderten Fassungen.
*/
class LegalAcceptanceController extends Controller
{
public function __construct(private readonly LegalAcceptanceGate $gate) {}
public function index(Request $request): JsonResponse
{
$required = $this->gate->requiredFor($request->user())->map(fn (LegalDocument $d) => $this->gate->describe($d))->values();
return response()->json([
'gate_enabled' => $this->gate->isEnabled(),
'required' => $required,
'accepted' => LegalDocumentAcceptance::query()
->where('user_id', $request->user()->getKey())
->orderByDesc('id')
->limit(20)
->get(['type', 'version', 'context', 'accepted_at'])
->map(fn (LegalDocumentAcceptance $a) => ['type' => $a->type, 'version' => $a->version, 'context' => $a->context, 'accepted_at' => $a->accepted_at->toIso8601String()])
->values(),
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
$validated = $request->validate([
'documents' => ['required', 'array', 'min:1'],
'documents.*.type' => ['required', Rule::in(LegalAcceptanceGate::TYPES)],
'documents.*.version' => ['required', 'string', 'max:10'],
]);
$documents = [];
foreach ($validated['documents'] as $item) {
$current = LegalDocument::current($item['type']);
if ($current === null || $current->version !== $item['version']) {
return response()->json([
'message' => sprintf('Version %s of %s is not the current one (current: %s).', $item['version'], $item['type'], $current !== null ? $current->version : 'none'),
], 422);
}
$documents[] = $current;
}
$this->gate->accept($request->user(), $documents, LegalDocumentAcceptance::CONTEXT_API, $request->ip(), $request->userAgent());
return $this->index($request)->setStatusCode(201);
}
}

View file

@ -14,6 +14,7 @@ use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Legal\LegalAcceptanceGate;
use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
@ -208,6 +209,18 @@ class PressReleaseController extends Controller
], 409);
}
// Zustimmungsgate (Block G, O2): ohne Annahme der aktuellen AGB- und
// Richtlinien-Fassung 428 mit den geforderten Versionen.
$gate = app(LegalAcceptanceGate::class);
$requiredDocuments = $gate->requiredFor($request->user());
if ($requiredDocuments->isNotEmpty()) {
return response()->json([
'message' => 'The current terms and guidelines must be accepted before submitting (POST /api/v1/legal/acceptances).',
'required_documents' => $requiredDocuments->map(fn ($document) => $gate->describe($document))->values(),
], 428);
}
$pressRelease->update([
'ai_text_declaration' => $validated['ai_text_declaration'],
'ai_declaration_confirmed_at' => now(),

View file

@ -3,6 +3,8 @@
namespace App\Livewire\Concerns;
use App\Enums\AiTextDeclaration;
use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
@ -21,19 +23,51 @@ trait InteractsWithAiDeclaration
public bool $aiDeclarationConfirmed = false;
/**
* Zustimmungsgate (Bildkennzeichnung Block G): aktive Annahme der
* aktuellen AGB- und Richtlinien-Fassung, wenn das Gate an ist und eine
* Fassung noch nicht angenommen wurde.
*/
public bool $legalAcceptanceConfirmed = false;
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
*/
protected function validateAiDeclaration(): void
{
$this->validate([
$rules = [
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
], [
];
$messages = [
'aiTextDeclaration.required' => __('Bitte angeben, ob und wie generative KI beim Text eingesetzt wurde.'),
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
]);
];
if (app(LegalAcceptanceGate::class)->requiredFor(auth()->user())->isNotEmpty()) {
$rules['legalAcceptanceConfirmed'] = ['accepted'];
$messages['legalAcceptanceConfirmed.accepted'] = __('Bitte nehmen Sie die aktuelle Fassung der AGB und der Veröffentlichungsrichtlinien an.');
}
$this->validate($rules, $messages);
}
/**
* Annahme protokollieren (nach validateAiDeclaration, vor dem Einreichen).
* Ohne Gate oder ohne offene Fassung passiert nichts.
*/
protected function persistLegalAcceptances(): void
{
$gate = app(LegalAcceptanceGate::class);
$required = $gate->requiredFor(auth()->user());
if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed) {
return;
}
$gate->accept(auth()->user(), $required, LegalDocumentAcceptance::CONTEXT_SUBMIT, request()->ip(), request()->userAgent());
$this->legalAcceptanceConfirmed = false;
}
/**

View file

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Aktive Annahme einer Rechtstext-Fassung durch einen Nutzer (Block G).
* Insert-only.
*/
class LegalDocumentAcceptance extends Model
{
public const UPDATED_AT = null;
public const CONTEXT_SUBMIT = 'submit';
public const CONTEXT_API = 'api';
public const CONTEXT_GATE = 'gate';
protected $fillable = [
'user_id',
'legal_document_id',
'type',
'version',
'context',
'ip_hmac',
'user_agent',
'accepted_at',
'created_at',
];
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'created_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('legal_document_acceptances ist insert-only.'));
static::deleting(fn () => throw new \LogicException('legal_document_acceptances wird nicht gelöscht.'));
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo<LegalDocument, $this>
*/
public function legalDocument(): BelongsTo
{
return $this->belongsTo(LegalDocument::class);
}
}

View file

@ -17,6 +17,7 @@ use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use App\Support\ContentFingerprint;
use App\Support\ImageCredit;
use Closure;
use Database\Factories\PressReleaseFactory;
use Illuminate\Database\Eloquent\Builder;
@ -263,6 +264,17 @@ class PressRelease extends Model
* Fallback Original. Null bei bildlosen PMs (dann greift
* chosenPlaceholderUrl() bzw. der generische Fallback der Komponente).
*/
/**
* Nachweiszeile des Titelbilds für Teaser (Bildkennzeichnung H.2):
* „Foto: …" bzw. „Quelle: …" plus KI-Zusatz, null ohne Bild.
*/
public function cardImageCredit(): ?string
{
$image = $this->relationLoaded('images') ? $this->images->first() : null;
return $image ? ImageCredit::for($image, $this->company)->line() : null;
}
public function cardImageUrl(): ?string
{
$image = $this->images?->first();

View file

@ -75,6 +75,12 @@ class ImageService
public const MAX_PRESS_RELEASE_IMAGE_BYTES = 16 * 1024 * 1024; // 16 MB
/**
* Schrift für den eingebrannten Nachweisbalken der OG-Variante
* (Bildkennzeichnung H.1, O4): Inter SemiBold, OFL 1.1, im Repository.
*/
public const OG_CAPTION_FONT = 'fonts/Inter_18pt-SemiBold.ttf';
public function __construct(private readonly string $disk = 'public') {}
/**
@ -144,6 +150,7 @@ class ImageService
string $diskName = 'local',
?array $focus = null,
bool $noCrop = false,
?string $ogCaption = null,
): array {
$this->assertValidPressReleaseImageUpload($upload);
@ -167,6 +174,7 @@ class ImageService
cover: true,
focus: $focus,
noCrop: $noCrop,
ogCaption: $ogCaption,
);
} finally {
// Die Arbeitskopie im Derivat-Ordner wird immer entfernt — auch
@ -284,7 +292,121 @@ class ImageService
* @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 $focus = null, bool $noCrop = false): array
/**
* OG-Variante neu erzeugen (Bildkennzeichnung H.1): aus der größten
* vorhandenen Variante, mit eingebranntem Nachweisbalken. Nötig nach
* einer Korrektur des Nachweises (E.6) und für den Bestand ohne
* OG-Variante. Liefert den Variantenpfad oder null.
*
* @param array<string, string>|null $variants
* @param array{x: int, y: int}|null $focus
*/
public function regenerateOgVariant(string $diskName, ?array $variants, ?string $fallbackPath, ?array $focus, bool $noCrop, ?string $caption): ?string
{
$disk = $this->disk($diskName);
$source = null;
foreach (['large', 'cover', 'medium'] as $key) {
if (is_string($variants[$key] ?? null) && $disk->exists($variants[$key])) {
$source = $variants[$key];
break;
}
}
$source ??= (is_string($fallbackPath) && $disk->exists($fallbackPath)) ? $fallbackPath : null;
if ($source === null) {
return null;
}
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION) ?: 'jpg');
$sourceImage = $this->createImageResource($disk->path($source), $extension);
if (! $sourceImage) {
return null;
}
try {
$spec = self::PRESS_RELEASE_IMAGE_VARIANTS['og'];
$ogPath = preg_replace('/-(large|cover|medium)\.([a-z0-9]+)$/i', '-og.$2', $source);
if (! is_string($ogPath) || $ogPath === $source) {
$ogPath = $this->variantPath($source, 'og');
}
$disk->makeDirectory(dirname($ogPath));
$resized = $noCrop
? $this->resizeContained($sourceImage, $spec['width'], $spec['height'], opaque: $extension === 'jpg')
: $this->resizeCover($sourceImage, $spec['width'], $spec['height'], $focus);
if ($caption !== null && $caption !== '') {
$this->burnCaption($resized, $caption);
}
$written = $this->writeImage($resized, $disk->path($ogPath), $extension);
imagedestroy($resized);
return $written ? $ogPath : null;
} finally {
imagedestroy($sourceImage);
}
}
/**
* Halbtransparenter Balken am unteren Rand mit dem Nachweis in Inter
* SemiBold; der Text wird auf die Breite gekürzt. Ohne Schrift bleibt
* das Bild unverändert (H.1, O4).
*/
private function burnCaption(\GdImage $image, string $caption): void
{
$font = resource_path(self::OG_CAPTION_FONT);
if (! is_file($font) || ! function_exists('imagettftext')) {
return;
}
$width = imagesx($image);
$height = imagesy($image);
$bar = max(44, (int) round($height * 0.105));
$size = max(14, (int) round($height * 0.034));
$padding = max(16, (int) round($width * 0.023));
imagealphablending($image, true);
$shade = imagecolorallocatealpha($image, 0, 0, 0, 38);
imagefilledrectangle($image, 0, $height - $bar, $width, $height, $shade);
$text = $this->fitCaption($caption, $font, $size, $width - 2 * $padding);
$box = imagettfbbox($size, 0, $font, $text);
$textHeight = abs($box[7] - $box[1]);
$baseline = $height - (int) round(($bar - $textHeight) / 2);
$white = imagecolorallocate($image, 255, 255, 255);
imagettftext($image, $size, 0, $padding, $baseline, $white, $font, $text);
}
private function fitCaption(string $caption, string $font, int $size, int $maxWidth): string
{
$text = trim(preg_replace('/\s+/u', ' ', $caption) ?? $caption);
while ($text !== '') {
$box = imagettfbbox($size, 0, $font, $text);
if (abs($box[2] - $box[0]) <= $maxWidth) {
return $text;
}
$text = rtrim(mb_substr($text, 0, max(0, mb_strlen($text) - 4))).'…';
if (mb_strlen($text) <= 4) {
return $text;
}
}
return $text;
}
private function generateVariants(Filesystem $disk, string $relativePath, string $extension, array $variantSpecs, bool $cover = false, ?array $focus = null, bool $noCrop = false, ?string $ogCaption = null): array
{
$absolute = $disk->path($relativePath);
$sourceImage = $this->createImageResource($absolute, $extension);
@ -318,6 +440,11 @@ class ImageService
? $this->resizeCover($sourceImage, $targetWidth, $targetHeight, $focus)
: $this->resizeContained($sourceImage, $targetWidth, $targetHeight, opaque: $cover && $extension === 'jpg');
// OpenGraph-Vorschau trägt den Nachweis eingebrannt (H.1, E7).
if ($key === 'og' && $ogCaption !== null && $ogCaption !== '') {
$this->burnCaption($resized, $ogCaption);
}
$written = $this->writeImage($resized, $variantAbsolute, $extension);
imagedestroy($resized);

View file

@ -0,0 +1,104 @@
<?php
namespace App\Services\Legal;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Zustimmungsgate vor jeder Einreichung (Bildkennzeichnung Block G,
* Decision-Update E12): AGB und Richtlinien müssen in der aktuellen Fassung
* aktiv angenommen sein — kein „Weiternutzung gilt als Zustimmung".
* Bestandskunden werden beim nächsten Einreichen einmalig durchgeschleust.
*
* Hinter LEGAL_ACCEPTANCE_GATE, aktiv erst mit AGB v0.7 (Deploy 3), damit
* niemand zweimal klickt.
*/
class LegalAcceptanceGate
{
/** @var list<string> */
public const TYPES = [LegalDocument::TYPE_AGB, LegalDocument::TYPE_RICHTLINIEN];
public function isEnabled(): bool
{
return (bool) config('legal.acceptance_gate', false);
}
/**
* Fassungen, die der Nutzer noch nicht angenommen hat (leer, wenn das
* Gate aus ist oder alles angenommen wurde).
*
* @return Collection<int, LegalDocument>
*/
public function requiredFor(?User $user): Collection
{
if (! $this->isEnabled() || $user === null) {
return collect();
}
return collect(self::TYPES)
->map(fn (string $type): ?LegalDocument => LegalDocument::current($type))
->filter()
->reject(fn (LegalDocument $document): bool => $this->hasAccepted($user, $document))
->values();
}
public function hasAccepted(User $user, LegalDocument $document): bool
{
return LegalDocumentAcceptance::query()
->where('user_id', $user->getKey())
->where('type', $document->type)
->where('version', $document->version)
->exists();
}
/**
* @param iterable<int, LegalDocument> $documents
* @return list<LegalDocumentAcceptance>
*/
public function accept(User $user, iterable $documents, string $context, ?string $ip = null, ?string $userAgent = null): array
{
$rows = [];
foreach ($documents as $document) {
if ($this->hasAccepted($user, $document)) {
continue;
}
$rows[] = LegalDocumentAcceptance::query()->create([
'user_id' => $user->getKey(),
'legal_document_id' => $document->getKey(),
'type' => $document->type,
'version' => $document->version,
'context' => $context,
'ip_hmac' => ImageRightsConfirmation::hashIp($ip),
'user_agent' => $userAgent !== null ? Str::limit($userAgent, 250, '') : null,
'accepted_at' => now(),
'created_at' => now(),
]);
}
return $rows;
}
/**
* Anzeige-/API-Form einer Fassung.
*
* @return array{type: string, version: string, label: string, url: string}
*/
public function describe(LegalDocument $document): array
{
$route = $document->type === LegalDocument::TYPE_RICHTLINIEN ? 'richtlinien' : 'agb';
return [
'type' => $document->type,
'version' => $document->version,
'label' => $document->type === LegalDocument::TYPE_RICHTLINIEN ? __('Veröffentlichungsrichtlinien') : __('Allgemeine Geschäftsbedingungen'),
'url' => route($route, ['edition' => 'de']),
];
}
}

View file

@ -8,6 +8,8 @@ use App\Models\ImageDeclarationCorrection;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
@ -22,6 +24,7 @@ class ImageCorrectionService
public function __construct(
private readonly PublishedImageSnapshot $snapshots,
private readonly PressReleaseArchiver $archiver,
private readonly ImageService $imageService,
) {}
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
@ -56,6 +59,26 @@ class ImageCorrectionService
});
}
/**
* Der eingebrannte Nachweis der OG-Variante muss der Korrektur folgen (H.1).
*/
public function refreshOgVariant(PressReleaseImage $image): void
{
$caption = ImageCredit::for($image, $image->pressRelease?->company)->line();
$path = $this->imageService->regenerateOgVariant(
$image->disk,
$image->variants,
$image->path,
$image->focus_x !== null && $image->focus_y !== null ? ['x' => $image->focus_x, 'y' => $image->focus_y] : null,
(bool) $image->no_crop,
$caption,
);
if ($path !== null) {
$image->forceFill(['variants' => array_merge((array) $image->variants, ['og' => $path])])->save();
}
}
private function apply(PressReleaseImage $image, User $admin, string $field, ?string $old, ?string $new, ?string $oldSource, string $reason, \Closure $mutation): ImageDeclarationCorrection
{
$correction = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation): ImageDeclarationCorrection {
@ -77,6 +100,10 @@ class ImageCorrectionService
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
// Seiten-Caches verwerfen (D.5). Jede Korrektur zusätzlich als
// Archiveintrag label_corrected (Block F.4).
if (in_array($field, [ImageDeclarationCorrection::FIELD_CREDIT, ImageDeclarationCorrection::FIELD_AI_DECLARATION], true)) {
$this->refreshOgVariant($image->fresh());
}
if ($image->wasEverPublished()) {
$this->snapshots->capture($image->fresh());
WebCaches::forgetPublicPages();

View file

@ -14,6 +14,7 @@ use App\Services\Image\ImageProvenanceInspector;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\Image\ProvenanceResult;
use App\Support\ImageCredit;
use App\Support\WebCaches;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
@ -109,14 +110,17 @@ class PressReleaseImageUploader
throw new RuntimeException('Hash von Original und Prüfung stimmen nicht überein.');
}
$effective = $this->effectiveDeclaration($submission->aiDeclaration, $provenance);
$stored = $this->imageService->storePressReleaseImage(
$upload,
$pressRelease->id,
focus: $submission->focus,
noCrop: $submission->noCrop,
ogCaption: ImageCredit::compose($submission->copyright, $effective['declaration']?->publicLabel()),
);
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl): PressReleaseImage {
$image = DB::transaction(function () use ($pressRelease, $submission, $actor, $legalText, $stored, $provenance, $sha256, $originalsDisk, $originalPath, $agbUrl, $effective): PressReleaseImage {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
@ -127,8 +131,6 @@ class PressReleaseImageUploader
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'],

View file

@ -75,6 +75,26 @@ final readonly class ImageCredit
);
}
/**
* Zeile aus Einzelteilen — für den eingebrannten OG-Balken beim Upload,
* bevor es ein Modell gibt (H.1).
*/
public static function compose(?string $credit, ?string $aiLabel, ?string $label = null): ?string
{
$credit = self::clean($credit);
$parts = [];
if ($credit !== null) {
$parts[] = ($label ?? __('web.detail.photo')).': '.$credit;
}
if ($aiLabel !== null && $aiLabel !== '') {
$parts[] = $aiLabel;
}
return $parts === [] ? null : implode(' · ', $parts);
}
/**
* Sichtbare Zeile unter dem Bild, z. B. „Foto: Musterfirma · KI-generiert".
*/