api: Bildupload v1 unter Formular v2 (Pflichtfelder, Kennung des Wortlauts, Bestätigung mit Token), Legacy-Bildupload abgeschaltet, API-Doku (Bildkennzeichnung Block C)

- StorePressReleaseImageRequest: copyright, rights_confirmed, confirm_text_version Pflicht; 422 mit aktueller Kennung; 16 MiB
- PressReleaseImageController über den gemeinsamen Uploader, Löschen über den Remover; Resource mit Portalkennzeichnung, Kundenangabe, Herkunft, Bestätigung und warnings[]
- POST /service/pressreleases/{id}/images antwortet 410 mit Hinweis auf v1 (E-B4)
- API-Seite und Sprachdateien: Felder und Rechtehinweis mit aktiver Textkennung; Paritätsdokument ergänzt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:35:52 +00:00
parent 3d3ddc2dc8
commit 24184f1dec
9 changed files with 411 additions and 30 deletions

View file

@ -25,6 +25,15 @@ class LegacyPressReleaseImageController extends Controller
public function store(Request $request, int $pressRelease): JsonResponse
{
// Bildkennzeichnung E-B4 (22.09.2026): Der Alt-Upload kennt weder
// Bildnachweis noch Rechtebestätigung und wird nicht genutzt — mit
// Formular v2 abgeschaltet. Wer Bilder liefern will, nimmt v1.
if (config('images.rights_v2')) {
return $this->legacyError(410, [
'Image upload via the legacy service API has been discontinued. Use POST /api/v1/press-releases/{id}/images with copyright, ai_declaration, rights_confirmed and confirm_text_version.',
]);
}
abort_unless($request->user()->tokenCan('press-release-images:write'), 403);
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->find($pressRelease);

View file

@ -11,6 +11,11 @@ use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@ -20,7 +25,11 @@ use RuntimeException;
class PressReleaseImageController extends Controller
{
public function __construct(private readonly ImageService $imageService) {}
public function __construct(
private readonly ImageService $imageService,
private readonly PressReleaseImageUploader $uploader,
private readonly PressReleaseImageRemover $remover,
) {}
public function index(Request $request, int $pressRelease): AnonymousResourceCollection
{
@ -49,6 +58,13 @@ class PressReleaseImageController extends Controller
/** @var UploadedFile $file */
$file = $request->file('image');
// Formular v2 (Bildkennzeichnung Block C): derselbe Ablauf wie im
// Kundenformular — Original, Herkunftsprüfung, Bestätigung mit
// Token-ID. Ein zweites Bild wird abgelehnt (ein Titelbild je PM).
if (config('images.rights_v2')) {
return $this->storeUnderRightsV2($request, $pressRelease, $validated, $file);
}
try {
$stored = $this->imageService->storePressReleaseImage($file, $pressRelease->id);
} catch (RuntimeException $exception) {
@ -65,10 +81,8 @@ class PressReleaseImageController extends Controller
'disk' => $stored['disk'],
'path' => $stored['path'],
'variants' => $stored['variants'],
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest);
// Legacy-/service-Calls ohne Feld gelten als Originalfoto
// (Kompatibilitäts-Default, Entscheidung 10.07.2026).
'ai_declaration' => $validated['ai_declaration'] ?? AiImageDeclaration::Original->value,
// v1 verlangt die KI-Kennzeichnung je Bild (FormRequest).
'ai_declaration' => $validated['ai_declaration'],
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'copyright' => $validated['copyright'] ?? null,
@ -102,12 +116,63 @@ class PressReleaseImageController extends Controller
], 409);
}
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
if (config('images.rights_v2')) {
$this->remover->remove($image);
} else {
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
}
return response()->noContent();
}
/**
* @param array<string, mixed> $validated
*/
private function storeUnderRightsV2(StorePressReleaseImageRequest $request, PressRelease $pressRelease, array $validated, UploadedFile $file): JsonResponse
{
$focus = isset($validated['focus_x'], $validated['focus_y'])
? ['x' => (int) $validated['focus_x'], 'y' => (int) $validated['focus_y']]
: null;
$submission = new ImageSubmission(
copyright: (string) $validated['copyright'],
aiDeclaration: AiImageDeclaration::from($validated['ai_declaration']),
rightsConfirmed: (bool) $validated['rights_confirmed'],
legalTextKey: (string) $validated['confirm_text_version'],
title: $validated['title'] ?? null,
description: $validated['description'] ?? null,
licenseUrl: $validated['license_url'] ?? null,
sourceUrl: $validated['source_url'] ?? null,
focus: $focus,
noCrop: (bool) ($validated['no_crop'] ?? false),
);
$actor = ImageUploadActor::api(
$request->user(),
$request->user()->currentAccessToken()?->getKey(),
$request->ip(),
$request->userAgent(),
);
try {
$image = $this->uploader->store($file, $pressRelease, $submission, $actor);
} catch (ImageUploadException $exception) {
return response()->json([
'message' => $exception->getMessage(),
'errors' => [$exception->field => [$exception->getMessage()]],
], 422);
} catch (RuntimeException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], 422);
}
return PressReleaseImageResource::make($image->load('latestRightsConfirmation'))
->response()
->setStatusCode(201);
}
private function findOwnedPressRelease(int $pressReleaseId, Request $request): ?PressRelease
{
return PressRelease::withoutGlobalScope(PortalScope::class)

View file

@ -3,9 +3,11 @@
namespace App\Http\Requests\Api\V1;
use App\Enums\AiImageDeclaration;
use App\Services\Image\ImageRightsTextRenderer;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StorePressReleaseImageRequest extends FormRequest
{
@ -20,27 +22,76 @@ class StorePressReleaseImageRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* KI-Kennzeichnung je Bild (Art. 50 KI-VO) ist im v1-Vertrag Pflicht;
* Legacy-/service-Clients kennen das Feld nicht — dort greift der
* Kompatibilitäts-Default „original" (Entscheidung 10.07.2026).
* KI-Kennzeichnung je Bild (Art. 50 KI-VO) ist im v1-Vertrag Pflicht.
* Mit Formular v2 (Bildkennzeichnung Block C, Decision-Update S4) kommen
* Bildnachweis, Rechtebestätigung und die Kennung des bestätigten
* Wortlauts als Pflichtfelder dazu; Lizenz- und Quellenlink,
* Fokuspunkt und Letterbox bleiben optional.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$isLegacyServiceCall = str_starts_with((string) $this->route()?->getName(), 'legacy-service.');
$rightsV2 = (bool) config('images.rights_v2');
$maxKilobytes = $rightsV2 ? (int) ((int) config('images.max_bytes') / 1024) : 8192;
return [
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:8192'],
'ai_declaration' => [
Rule::requiredIf(! $isLegacyServiceCall),
'nullable',
Rule::enum(AiImageDeclaration::class),
],
$rules = [
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:'.$maxKilobytes],
'ai_declaration' => ['required', Rule::enum(AiImageDeclaration::class)],
'title' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:500'],
'copyright' => ['nullable', 'string', 'max:255'],
'copyright' => [$rightsV2 ? 'required' : 'nullable', 'string', 'max:255'],
'is_preview' => ['nullable', 'boolean'],
];
if ($rightsV2) {
$rules += [
'rights_confirmed' => ['required', 'accepted'],
'confirm_text_version' => ['required', 'string', 'max:40'],
'license_url' => ['nullable', 'url:http,https', 'max:2048'],
'source_url' => ['nullable', 'url:http,https', 'max:2048'],
'focus_x' => ['nullable', 'integer', 'between:0,100'],
'focus_y' => ['nullable', 'integer', 'between:0,100'],
'no_crop' => ['nullable', 'boolean'],
];
}
return $rules;
}
/**
* Die Kennung muss die heute aktive Fassung sein; sonst 422 mit Hinweis
* auf die aktuelle Kennung (Decision-Update 7.1: „sonst 422 mit Hinweis").
*/
public function after(): array
{
return [
function (Validator $validator): void {
if (! config('images.rights_v2') || $validator->errors()->has('confirm_text_version')) {
return;
}
$current = app(ImageRightsTextRenderer::class)->current();
$given = (string) $this->input('confirm_text_version');
if ($current === null || $given !== $current->key) {
$validator->errors()->add(
'confirm_text_version',
sprintf('The confirmation text version is not current. Current version: %s.', $current?->key ?? 'none'),
);
}
},
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'rights_confirmed.accepted' => 'The rights confirmation is required (rights_confirmed must be true).',
'copyright.required' => 'The image credit (copyright) is required.',
];
}
}

View file

@ -37,6 +37,24 @@ class PressReleaseImageResource extends JsonResource
'title' => $this->title,
'description' => $this->description,
'copyright' => $this->copyright,
// Bildkennzeichnung Block C: Portalkennzeichnung, Kundenangabe
// und Herkunft getrennt (Decision-Update S6); Bestätigung mit
// Kennung. `urls.original` bleibt das Cover — das Beweisoriginal
// ist nie über die API erreichbar (E.4).
'ai_declaration' => $this->ai_declaration?->value,
'ai_declaration_user' => $this->ai_declaration_user?->value,
'ai_declaration_source' => $this->ai_declaration_source?->value,
'ai_declaration_locked' => (bool) $this->ai_declaration_locked,
'provenance_status' => $this->provenance_status?->value,
'license_url' => $this->license_url,
'source_url' => $this->source_url,
'rights_confirmation' => $this->whenLoaded('latestRightsConfirmation', fn () => $this->latestRightsConfirmation === null ? null : [
'version' => $this->latestRightsConfirmation->legal_text_key,
'agb_version' => $this->latestRightsConfirmation->agb_version,
'confirmed_at' => $this->latestRightsConfirmation->confirmed_at?->toIso8601String(),
'channel' => $this->latestRightsConfirmation->channel?->value,
]),
'warnings' => $this->warnings(),
'is_preview' => $this->is_preview,
'sort_order' => $this->sort_order,
'width' => $this->width,
@ -46,4 +64,25 @@ class PressReleaseImageResource extends JsonResource
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
/**
* Maschinenlesbare Hinweise (Feedback F04): Prüfung fehlgeschlagen,
* Kennzeichnung durch geprüfte Herkunftsdaten angehoben (Phase 2).
*
* @return list<string>
*/
private function warnings(): array
{
$warnings = [];
if ($this->provenance_status?->value === 'error') {
$warnings[] = 'provenance_check_failed';
}
if ((bool) $this->ai_declaration_locked && $this->ai_declaration !== $this->ai_declaration_user) {
$warnings[] = 'ai_declaration_raised_by_metadata';
}
return $warnings;
}
}