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
b5b70416f2
commit
075d0d931f
27 changed files with 866 additions and 15 deletions
67
app/Http/Controllers/Api/V1/LegalAcceptanceController.php
Normal file
67
app/Http/Controllers/Api/V1/LegalAcceptanceController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
63
app/Models/LegalDocumentAcceptance.php
Normal file
63
app/Models/LegalDocumentAcceptance.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue