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:
parent
3d3ddc2dc8
commit
24184f1dec
9 changed files with 411 additions and 30 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue