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:
parent
0240c143c8
commit
62723b551a
27 changed files with 866 additions and 15 deletions
|
|
@ -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);
|
||||
|
|
|
|||
104
app/Services/Legal/LegalAcceptanceGate.php
Normal file
104
app/Services/Legal/LegalAcceptanceGate.php
Normal 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue