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');
+});