From 24184f1dec475cc4123136b4ffbec5778034e087 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 22 Sep 2026 09:35:52 +0000 Subject: [PATCH] =?UTF-8?q?api:=20Bildupload=20v1=20unter=20Formular=20v2?= =?UTF-8?q?=20(Pflichtfelder,=20Kennung=20des=20Wortlauts,=20Best=C3=A4tig?= =?UTF-8?q?ung=20mit=20Token),=20Legacy-Bildupload=20abgeschaltet,=20API-D?= =?UTF-8?q?oku=20(Bildkennzeichnung=20Block=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../LegacyPressReleaseImageController.php | 9 + .../Api/V1/PressReleaseImageController.php | 79 +++++++- .../Api/V1/StorePressReleaseImageRequest.php | 75 ++++++-- .../Resources/PressReleaseImageResource.php | 39 ++++ dev/betrieb/api-legacy-paritaet.md | 3 +- lang/de/static.php | 14 +- lang/en/static.php | 12 +- resources/views/web/api.blade.php | 31 ++- .../V1/PressReleaseImageRightsV2ApiTest.php | 179 ++++++++++++++++++ 9 files changed, 411 insertions(+), 30 deletions(-) create mode 100644 tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php index baac1074..27f7fe4c 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php @@ -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); diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index dfb910fd..4b8a1091 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -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 $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) diff --git a/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php b/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php index 5536d051..cd6079cd 100644 --- a/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php +++ b/app/Http/Requests/Api/V1/StorePressReleaseImageRequest.php @@ -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> */ 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 + */ + 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.', + ]; } } diff --git a/app/Http/Resources/PressReleaseImageResource.php b/app/Http/Resources/PressReleaseImageResource.php index 89e4bfa8..7e2845ba 100644 --- a/app/Http/Resources/PressReleaseImageResource.php +++ b/app/Http/Resources/PressReleaseImageResource.php @@ -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 + */ + 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; + } } diff --git a/dev/betrieb/api-legacy-paritaet.md b/dev/betrieb/api-legacy-paritaet.md index 0a03a421..855a2e56 100644 --- a/dev/betrieb/api-legacy-paritaet.md +++ b/dev/betrieb/api-legacy-paritaet.md @@ -34,7 +34,7 @@ | `GET /service/pressreleases.json` | **Öffentlicher Index**: nur `status=published`, portalweit (NICHT auf den User beschränkt!), Filter `query[languages][]`, `query[categories][]`, `query[offset]`, `query[limit]`, sortiert `created_at DESC`. Response: **JSON-Array von Show-URLs** (Strings), keine Objekte. | | `GET /service/pressreleases/{id}.json` | Nur `published` (sonst 404; inaktive Firma → 404). Zählt Hits (session-dedupliziert). Response: flaches Objekt `{id, title, text, teaser_end, language, category_id, view_url, company: {viewUrl, name}}`. | | `GET /service/pressreleases/{id}/images.json` | Nur bei `published` PM. Response: **Array von Bild-Show-URLs**. | -| `POST /service/pressreleases/{id}/images.json` | Nur Eigentümer (sonst 403 „You are not the creator …"). Multipart, Felder verschachtelt `press_release_image[image]` (+ title/description/copyright). Response: `201` + `Location`, Body `{}`. | +| `POST /service/pressreleases/{id}/images.json` | Nur Eigentümer (sonst 403 „You are not the creator …"). Multipart, Felder verschachtelt `press_release_image[image]` (+ title/description/copyright). Response: `201` + `Location`, Body `{}`. **Seit Bildkennzeichnung E-B4 (22.09.2026) mit `IMAGE_RIGHTS_V2=true` abgeschaltet: `410` mit Hinweis auf v1** — der Alt-Vertrag kennt weder Bildnachweis noch Rechtebestätigung und wurde nie genutzt. | | `GET /service/pressreleases/{id}/images/{imgId}.json` | Response: `{id, title, description, copyright, image_file_url}`. | | `GET /service/users/me/companies.json` | Eigene Firmen; `limit`/`offset` direkt ODER `query[limit]`/`query[offset]`. Response: **Array von Show-URLs**. | | `POST /service/users/me/companies.json` | Firma anlegen (`201` + `Location`, Body `{}`). | @@ -113,6 +113,7 @@ Scope nach Log-Auswertung (§3a): **die 5 connektar-Endpoints**, verhaltensgleic - [x] `POST /service/users/me/companies/{id}/contacts` — Felder `contact[salutation_id|title|first_name|last_name|responsibility|phone|fax|email]`; Zugriff nur eigene Firma (403-Wortlaut s. §1); Response wie oben. - [x] `POST /service/pressreleases` — Felder `press_release[company_id|contacts_list[]|category_id|language|title|text|backlink_url|…]`; Statusverhalten s. S2; KI-Deklarations-Default none (erledigt 10.07.); Response `201` + `Location` + `{}`. - [x] `POST /service/pressreleases/{id}/images` — Multipart `press_release_image[image|title|description|copyright]`; Eigentümer-Check (403 „You are not the creator of this press release"); ai_declaration-Default original (erledigt 10.07.); Response `201` + `Location` + `{}`. + - **22.09.2026:** hinter `IMAGE_RIGHTS_V2` abgeschaltet (410, Bildkennzeichnung E-B4). Der Default „original“ war eine erfundene Erklärung (Decision-Update S5). - [x] `GET /service/pressreleases/{id}` — nur `published` (sonst 404 — connektars Polling-Kontrolle!), Hit-Zählung, flaches Alt-Objekt `{id,title,text,teaser_end,language,category_id,view_url,company:{viewUrl,name}}`. - [x] Alt-Fehlerformat überall: `{"global_errors":[…],"field_errors":{…}}` (422) bzw. `{"global_errors":[…]}`. - [x] Pest-Paritäts-Tests (`LegacyServiceParityTest`) — je Endpoint gegen den §1-Vertrag (inkl. connektar-Flow end-to-end: Firma → Kontakt → PM → Bild → GET). diff --git a/lang/de/static.php b/lang/de/static.php index 9fa43814..ebddc239 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -666,10 +666,17 @@ return [ 'field_keywords' => 'Schlagworte, kommagetrennt, max. 255 Zeichen.', 'field_teaser' => 'Zeichen-Offsets für den Teaser-Ausschnitt.', 'field_no_export' => 'Mitteilung von Feeds/Exporten ausnehmen.', - 'field_image' => 'JPG, PNG oder WebP, max. 8 MB.', + 'field_image' => 'JPG, PNG oder WebP, max. :mb MB, höchstens 8.000 Pixel je Seite. Ein Titelbild je Mitteilung.', + 'field_image_ai_declaration' => 'KI-Kennzeichnung des Bildes: original (ohne generative KI), ai_generated, ai_edited. Enthält die Datei geprüfte Herkunftsdaten, gilt deren Kennzeichnung; eine Abschwächung ist nicht möglich.', 'field_image_title' => 'Bildtitel, max. 120 Zeichen.', - 'field_image_description' => 'Beschreibung, max. 500 Zeichen.', - 'field_image_copyright' => 'Urheberangabe, max. 255 Zeichen.', + 'field_image_description' => 'Bildunterschrift, max. 500 Zeichen.', + 'field_image_copyright' => 'Bildnachweis, wie er unter dem Bild erscheint („Foto: …“), max. 255 Zeichen.', + 'field_image_rights_confirmed' => 'Muss true sein: Bestätigung, dass Sie zur Veröffentlichung berechtigt sind und die Angaben zutreffen (Wortlaut siehe Hinweis unten).', + 'field_image_confirm_text_version' => 'Kennung des bestätigten Wortlauts, derzeit :version. Eine veraltete Kennung wird mit 422 und der aktuellen Kennung beantwortet.', + 'field_image_license_url' => 'Link zur Lizenz, wenn eine Lizenz die Nutzung regelt (z. B. Creative Commons, Agentur).', + 'field_image_source_url' => 'Link zur Fundstelle oder Ursprungsseite — keine Lizenz.', + 'field_image_focus' => 'Fokuspunkt für den Zuschnitt in Prozent (0–100), Standard Bildmitte.', + 'field_image_no_crop' => 'true: nicht beschneiden, ganzes Bild mit neutralem Rand.', 'field_image_is_preview' => 'Als Vorschaubild setzen (ersetzt ein vorhandenes).', 'quickstart_heading' => 'Schneller Einstieg', @@ -678,6 +685,7 @@ return [ 'quickstart_step_2' => '# 2. Bild hochladen (multipart)', 'quickstart_step_3' => '# 3. Zur Inhaltsprüfung einreichen', 'ai_declaration_note' => 'Pflicht seit Juli 2026: Der Submit-Aufruf erklärt verbindlich den KI-Grad des Textes (ai_text_declaration: none, assisted, mostly_ai, fully_ai — Transparenzpflicht nach Art. 50 KI-VO) und schließt die Kenntnisnahme der dauerhaften Archivierung ein (AGB § 13). Jedes Bild trägt seine eigene Kennzeichnung (ai_declaration: original, ai_generated, ai_edited).', + 'image_rights_note' => 'Bilder sind optional. Wird ein Bild übermittelt, sind copyright, ai_declaration, rights_confirmed und confirm_text_version Pflicht. Das Portal liest Herkunftsdaten der Datei (IPTC, XMP; später Content Credentials) aus und speichert die Originaldatei zur Beweissicherung. Mit rights_confirmed bestätigen Sie für jedes Bild die Rechteerklärung in der Fassung :version; ihr Wortlaut ist Bestandteil der API-Nutzungsbedingungen. Bilder ohne die Pflichtfelder werden mit 422 abgelehnt, die Mitteilung bleibt unverändert.', 'openapi_note' => 'Maschinenlesbare Referenz:', 'openapi_link' => 'OpenAPI-Spezifikation (YAML)', diff --git a/lang/en/static.php b/lang/en/static.php index a0cf024f..bb0b3f6a 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -664,10 +664,17 @@ return [ 'field_keywords' => 'Keywords, comma-separated, max. 255 characters.', 'field_teaser' => 'Character offsets for the teaser excerpt.', 'field_no_export' => 'Exclude the release from feeds/exports.', - 'field_image' => 'JPG, PNG or WebP, max. 8 MB.', + 'field_image' => 'JPG, PNG or WebP, max. :mb MB, at most 8,000 pixels per side. One title image per release.', + 'field_image_ai_declaration' => 'AI label of the image: original (no generative AI), ai_generated, ai_edited. If the file carries verified provenance data, that label applies and cannot be weakened.', 'field_image_title' => 'Image title, max. 120 characters.', 'field_image_description' => 'Description, max. 500 characters.', - 'field_image_copyright' => 'Copyright attribution, max. 255 characters.', + 'field_image_copyright' => 'Image credit as shown below the image (“Photo: …”), max. 255 characters.', + 'field_image_rights_confirmed' => 'Must be true: confirmation that you are entitled to publish the image and that your details are correct (wording see note below).', + 'field_image_confirm_text_version' => 'Identifier of the confirmed wording, currently :version. An outdated identifier is answered with 422 and the current one.', + 'field_image_license_url' => 'Link to the licence if a licence governs the use (e.g. Creative Commons, agency).', + 'field_image_source_url' => 'Link to the source page — not a licence.', + 'field_image_focus' => 'Focus point for cropping in percent (0–100), default centre.', + 'field_image_no_crop' => 'true: do not crop, full image with neutral padding.', 'field_image_is_preview' => 'Set as preview image (replaces an existing one).', 'quickstart_heading' => 'Quick start', @@ -676,6 +683,7 @@ return [ 'quickstart_step_2' => '# 2. Upload an image (multipart)', 'quickstart_step_3' => '# 3. Submit for content review', 'ai_declaration_note' => 'Required since July 2026: the submit call bindingly declares the AI grade of the text (ai_text_declaration: none, assisted, mostly_ai, fully_ai — transparency obligation under Art. 50 EU AI Act) and includes acknowledgement of permanent archiving (Terms § 13). Each image carries its own label (ai_declaration: original, ai_generated, ai_edited).', + 'image_rights_note' => 'Images are optional. If an image is submitted, copyright, ai_declaration, rights_confirmed and confirm_text_version are required. The portal reads provenance data from the file (IPTC, XMP; later Content Credentials) and keeps the original file as evidence. With rights_confirmed you confirm the rights declaration in version :version for each image; its wording is part of the API terms of use. Images without the required fields are rejected with 422; the release itself is unaffected.', 'openapi_note' => 'Machine-readable reference:', 'openapi_link' => 'OpenAPI specification (YAML)', diff --git a/resources/views/web/api.blade.php b/resources/views/web/api.blade.php index 7841d1cf..e3ad4b7d 100644 --- a/resources/views/web/api.blade.php +++ b/resources/views/web/api.blade.php @@ -56,13 +56,27 @@ ['name' => 'no_export', 'type' => 'boolean', 'required' => false, 'desc' => __('static.api.field_no_export')], ]; - $imageFields = [ - ['name' => 'image', 'type' => 'file', 'required' => true, 'desc' => __('static.api.field_image')], + // Bildkennzeichnung Block C: Mit Formular v2 sind Bildnachweis, + // Rechtebestätigung und die Kennung des Wortlauts Pflicht. Die Kennung + // kommt aus dem Textarchiv (aktive Fassung), damit die Doku nie hinterherhinkt. + $imageRightsV2 = (bool) config('images.rights_v2'); + $confirmTextVersion = $imageRightsV2 ? app(\App\Services\Image\ImageRightsTextRenderer::class)->current()?->key : null; + $imageMaxMb = $imageRightsV2 ? (int) round((int) config('images.max_bytes') / 1024 / 1024) : 8; + + $imageFields = array_values(array_filter([ + ['name' => 'image', 'type' => 'file', 'required' => true, 'desc' => __('static.api.field_image', ['mb' => $imageMaxMb])], + ['name' => 'ai_declaration', 'type' => 'string', 'required' => true, 'desc' => __('static.api.field_image_ai_declaration')], + ['name' => 'copyright', 'type' => 'string', 'required' => $imageRightsV2, 'desc' => __('static.api.field_image_copyright')], + $imageRightsV2 ? ['name' => 'rights_confirmed', 'type' => 'boolean', 'required' => true, 'desc' => __('static.api.field_image_rights_confirmed')] : null, + $imageRightsV2 ? ['name' => 'confirm_text_version', 'type' => 'string', 'required' => true, 'desc' => __('static.api.field_image_confirm_text_version', ['version' => $confirmTextVersion ?? '—'])] : null, ['name' => 'title', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_image_title')], ['name' => 'description', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_image_description')], - ['name' => 'copyright', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_image_copyright')], + $imageRightsV2 ? ['name' => 'license_url', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_image_license_url')] : null, + $imageRightsV2 ? ['name' => 'source_url', 'type' => 'string', 'required' => false, 'desc' => __('static.api.field_image_source_url')] : null, + $imageRightsV2 ? ['name' => 'focus_x / focus_y', 'type' => 'integer', 'required' => false, 'desc' => __('static.api.field_image_focus')] : null, + $imageRightsV2 ? ['name' => 'no_crop', 'type' => 'boolean', 'required' => false, 'desc' => __('static.api.field_image_no_crop')] : null, ['name' => 'is_preview', 'type' => 'boolean', 'required' => false, 'desc' => __('static.api.field_image_is_preview')], - ]; + ])); $statusCodes = [ ['code' => '200 / 201 / 204', 'desc' => __('static.api.status_2xx')], @@ -191,7 +205,11 @@ curl -X POST {{ $apiBase }}/press-releases/123/images \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -F "image=@pressefoto.jpg" \ -F "ai_declaration=original" \ - -F "copyright=Mustermann GmbH" \ + -F "copyright=Mustermann GmbH" \@if ($imageRightsV2) + + -F "rights_confirmed=1" \ + -F "confirm_text_version={{ $confirmTextVersion ?? 'img-confirm-v2' }}" \@endif + -F "is_preview=1" {{ __('static.api.quickstart_step_3') }} @@ -202,6 +220,9 @@ curl -X POST {{ $apiBase }}/press-releases/123/submit \

{{ __('static.api.ai_declaration_note') }} + @if ($imageRightsV2) +

{{ __('static.api.image_rights_note', ['version' => $confirmTextVersion ?? '—']) }}

+ @endif

{{ __('static.api.openapi_note') }} {{ __('static.api.openapi_link') }}. diff --git a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php new file mode 100644 index 00000000..d5d903d1 --- /dev/null +++ b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php @@ -0,0 +1,179 @@ +seed(LegalDocumentSeeder::class); + $this->seed(LegalTextSeeder::class); + config()->set('images.rights_v2', true); +}); + +function rightsV2ApiDraft(): array +{ + $user = User::factory()->create(); + $pressRelease = PressRelease::factory()->create([ + 'user_id' => $user->id, + 'status' => PressReleaseStatus::Draft->value, + ]); + + Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); + + return compact('user', 'pressRelease'); +} + +function rightsV2ApiFixture(string $name): UploadedFile +{ + return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name))); +} + +test('credit, confirmation and text version are required under formular v2', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + 'image' => UploadedFile::fake()->image('foto.jpg', 1200, 800), + 'ai_declaration' => 'original', + ])->assertUnprocessable() + ->assertInvalid(['copyright', 'rights_confirmed', 'confirm_text_version']); + + expect($pressRelease->images()->count())->toBe(0) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); + +test('an outdated text version is rejected with the current key in the message', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + + $response = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + 'image' => UploadedFile::fake()->image('foto.jpg', 1200, 800), + 'ai_declaration' => 'original', + 'copyright' => 'Muster GmbH', + 'rights_confirmed' => true, + 'confirm_text_version' => 'img-confirm-v1', + ]); + + $response->assertUnprocessable()->assertInvalid(['confirm_text_version']); + + expect($response->json('errors.confirm_text_version.0'))->toContain('img-confirm-v2'); +}); + +test('a valid upload stores the confirmation with the token and returns the new fields', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease, 'user' => $user] = rightsV2ApiDraft(); + + $response = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + 'image' => rightsV2ApiFixture('xmp-ai-generated.jpg'), + 'ai_declaration' => 'ai_generated', + 'copyright' => 'KI-Bild / Musterfirma', + 'rights_confirmed' => true, + 'confirm_text_version' => 'img-confirm-v2', + 'license_url' => 'https://example.com/lizenz', + 'focus_x' => 20, + 'focus_y' => 80, + 'is_preview' => true, + ], ['User-Agent' => 'ConnektarBot/2.0']); + + $response->assertCreated() + ->assertJsonPath('data.ai_declaration', 'ai_generated') + ->assertJsonPath('data.ai_declaration_user', 'ai_generated') + ->assertJsonPath('data.ai_declaration_source', 'iptc_prefill') + ->assertJsonPath('data.ai_declaration_locked', false) + ->assertJsonPath('data.provenance_status', 'unsigned_metadata') + ->assertJsonPath('data.license_url', 'https://example.com/lizenz') + ->assertJsonPath('data.rights_confirmation.version', 'img-confirm-v2') + ->assertJsonPath('data.rights_confirmation.agb_version', '0.6') + ->assertJsonPath('data.rights_confirmation.channel', 'api') + ->assertJsonPath('data.warnings', []); + + $image = PressReleaseImage::query()->sole(); + $confirmation = ImageRightsConfirmation::query()->sole(); + + expect($image->rules_version)->toBe(config('images.rules_version')) + ->and($image->focus_x)->toBe(20) + ->and($image->focus_y)->toBe(80) + ->and($confirmation->channel)->toBe(ConfirmationChannel::Api) + ->and($confirmation->user_id)->toBe($user->id) + ->and($confirmation->personal_access_token_id)->not->toBeNull() + ->and($confirmation->user_agent)->toBe('ConnektarBot/2.0') + ->and($response->json('data.urls.original'))->toBe(route('media.press-release-images.show', ['image' => $image->id])) + ->and($response->json('data.path'))->not->toContain('press-release-originals'); +}); + +test('a second title image is rejected with 422 and the press release keeps its first', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + + $payload = fn (string $name) => [ + 'image' => UploadedFile::fake()->image($name, 800, 600), + 'ai_declaration' => 'original', + 'copyright' => 'Muster GmbH', + 'rights_confirmed' => true, + 'confirm_text_version' => 'img-confirm-v2', + ]; + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", $payload('eins.jpg'))->assertCreated(); + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", $payload('zwei.jpg')) + ->assertUnprocessable() + ->assertJsonPath('errors.image.0', 'Bitte löschen Sie zuerst das vorhandene Titelbild.'); + + expect($pressRelease->images()->count())->toBe(1); +}); + +test('deleting a never published image via api removes the original and the confirmation', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + + $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ + 'image' => UploadedFile::fake()->image('foto.jpg', 800, 600), + 'ai_declaration' => 'original', + 'copyright' => 'Muster GmbH', + 'rights_confirmed' => true, + 'confirm_text_version' => 'img-confirm-v2', + ])->assertCreated(); + + $image = PressReleaseImage::query()->sole(); + + $this->deleteJson("/api/v1/press-release-images/{$image->id}")->assertNoContent(); + + expect(PressReleaseImage::withTrashed()->count())->toBe(0) + ->and(ImageRightsConfirmation::query()->count())->toBe(0) + ->and(Storage::disk('local')->allFiles())->toBe([]); +}); + +test('the legacy service upload answers 410 with a pointer to v1', function () { + /** @var TestCase $this */ + ['pressRelease' => $pressRelease] = rightsV2ApiDraft(); + + $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ + 'press_release_image' => ['image' => UploadedFile::fake()->image('alt.jpg')], + ])->assertStatus(410) + ->assertJsonPath('global_errors.0', fn (string $message) => str_contains($message, '/api/v1/press-releases/{id}/images')); + + expect($pressRelease->images()->count())->toBe(0); +}); + +test('the api docs page lists the rights fields with the current text version', function () { + /** @var TestCase $this */ + $this->get('https://pressekonto.test/de/api') + ->assertSuccessful() + ->assertSeeText('rights_confirmed') + ->assertSeeText('confirm_text_version') + ->assertSeeText('img-confirm-v2'); +});