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 0240c143c8
commit 62723b551a
27 changed files with 866 additions and 15 deletions

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