- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit isPubliclyDeliverable() - B-03: Livewire-Temp-Upload 16 MiB - N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put() geprüft, alle Fassungen ins Archiv - O03: published_ever_at in der Freigabetransaktion (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur - B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre - B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag; Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert - B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme) - B-08, B-09, B-11, B-12: published_ever_at nur bei Status published, Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale, Verarbeitungsfehler als Formularmeldung - K-02-Teil: nach Kundenbestätigung erneut routen - OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08 (Nachweis per POST), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
528 lines
23 KiB
PHP
528 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PressRelease\Images;
|
|
|
|
use App\Enums\AiDeclarationSource;
|
|
use App\Enums\AiImageDeclaration;
|
|
use App\Enums\CreditSource;
|
|
use App\Enums\ImagePrecheckOutcome;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\ImageRightsConfirmation;
|
|
use App\Models\LegalText;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseImage;
|
|
use App\Scopes\PortalScope;
|
|
use App\Services\Image\ImageProvenanceInspector;
|
|
use App\Services\Image\ImageRightsTextRenderer;
|
|
use App\Services\Image\ImageService;
|
|
use App\Services\Image\Precheck\ImagePrecheckEvaluation;
|
|
use App\Services\Image\Precheck\ImagePrecheckResult;
|
|
use App\Services\Image\Precheck\ImagePrecheckService;
|
|
use App\Services\Image\ProvenanceResult;
|
|
use App\Support\ImageCredit;
|
|
use App\Support\WebCaches;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Gemeinsamer Upload-Ablauf für Titelbilder (Bildkennzeichnung A.6), den
|
|
* Formular, API v1 und Admin-Upload gleichermaßen nutzen:
|
|
*
|
|
* 1. Grenzen prüfen (E-B6), Bytes einmal lesen, SHA-256 bilden.
|
|
* 2. Beweisoriginal auf den privaten Originalpfad schreiben (E9, F05).
|
|
* 3. Herkunftsprüfung auf dieselben Bytes (A.5); KI-Vorab-Check (Block K)
|
|
* davor auf den Quellbytes — eine Ablehnung speichert nichts.
|
|
* 4. Derivate über ImageService; bei Fehler alles zurückrollen.
|
|
* 5. Effektive Kennzeichnung bestimmen: Kundenangabe und geprüfter Befund
|
|
* getrennt; in Phase 1 belegt ein ungesicherter Befund nur vor.
|
|
* 6. Transaktion mit Sperre auf der PM: erlaubter Status (Audit N07),
|
|
* ein Titelbild, Bildzeile, Bestätigung (außer Admin-Upload),
|
|
* Regelversion.
|
|
* 7. DB-Fehler → Dateien wieder löschen. Nachgelagerte Schritte (Cache)
|
|
* laufen erst nach dem Commit und außerhalb der Bereinigung (Audit N08).
|
|
*
|
|
* Für Newsroom-Bilder (Block J) wird das Subjekt verallgemeinert; Phase 1
|
|
* kennt nur PressRelease.
|
|
*/
|
|
class PressReleaseImageUploader
|
|
{
|
|
public function __construct(
|
|
private readonly ImageService $imageService,
|
|
private readonly ImageProvenanceInspector $inspector,
|
|
private readonly ImageRightsTextRenderer $textRenderer,
|
|
private readonly ImagePrecheckService $precheck,
|
|
private readonly PublishedImageSnapshot $snapshot,
|
|
) {}
|
|
|
|
/**
|
|
* Vorprüfung nach der Dateiauswahl (B.2): Grenzen und Herkunft, ohne
|
|
* etwas zu speichern. Das Ergebnis belegt das Formular vor; beim
|
|
* Speichern wird auf den finalen Bytes neu gerechnet.
|
|
*/
|
|
public function preflight(UploadedFile $upload): ProvenanceResult
|
|
{
|
|
$this->assertWithinLimits($upload);
|
|
|
|
return $this->inspector->inspect($upload->getRealPath());
|
|
}
|
|
|
|
/**
|
|
* KI-Vorab-Check nach der Dateiauswahl (Block K, DU 12.3). Liefert null,
|
|
* wenn der Check aus ist. Eine Ablehnung wirft sofort mit dem Grund in
|
|
* einem Satz und wird an der PM protokolliert; alles andere kommt als
|
|
* Ergebnis zurück, das `store()` über den Hash wiederverwendet.
|
|
*/
|
|
public function precheck(UploadedFile $upload, PressRelease $pressRelease, ?string $sha256 = null): ?ImagePrecheckResult
|
|
{
|
|
// Den Hash aus preflight() weiterreichen statt neu zu rechnen (OPT-4).
|
|
$result = $this->precheck->run($upload->getRealPath(), $sha256 ?? (string) hash_file('sha256', $upload->getRealPath()));
|
|
|
|
if ($result === null) {
|
|
return null;
|
|
}
|
|
|
|
$this->assertNotRejected($pressRelease, $result, null);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
|
|
* Rejected, Admin: alles außer Archived). Sie wird unter der Sperre
|
|
* erneut geprüft, damit ein Statuswechsel während Vorab-Check und
|
|
* Variantenerzeugung den Upload nicht überholt (Audit N07). Null = keine
|
|
* Regel (nur für Aufrufer, die den Status selbst sperren).
|
|
*
|
|
* @param list<PressReleaseStatus>|null $allowedStatuses
|
|
*/
|
|
public function store(
|
|
UploadedFile $upload,
|
|
PressRelease $pressRelease,
|
|
ImageSubmission $submission,
|
|
ImageUploadActor $actor,
|
|
?string $expectedSha256 = null,
|
|
?string $agbUrl = null,
|
|
?ImagePrecheckResult $precheck = null,
|
|
?array $allowedStatuses = null,
|
|
): PressReleaseImage {
|
|
$this->assertWithinLimits($upload);
|
|
|
|
$sourcePath = $upload->getRealPath();
|
|
$sha256 = (string) hash_file('sha256', $sourcePath);
|
|
|
|
// Bindung an die geprüfte Datei (F09): Wer zwischen Auswahl und
|
|
// Speichern die Datei tauscht, bekommt keine alte Vorbelegung.
|
|
if ($expectedSha256 !== null && ! hash_equals($expectedSha256, $sha256)) {
|
|
throw new ImageUploadException(__('Die Datei hat sich seit der Prüfung geändert. Bitte wählen Sie das Bild erneut aus.'));
|
|
}
|
|
|
|
$legalText = $this->resolveLegalText($submission, $actor);
|
|
|
|
// Vorab-Check vor jedem Schreiben (K.3): Ablehnung → nichts gespeichert.
|
|
$precheck = $this->precheck->resolve($sourcePath, $sha256, $precheck);
|
|
$precheckEvaluation = $precheck !== null ? $this->assertNotRejected($pressRelease, $precheck, $submission->aiDeclaration) : null;
|
|
|
|
$originalsDisk = (string) config('images.originals_disk', 'local');
|
|
$originalPath = sprintf(
|
|
'%s/%d/%s.%s',
|
|
config('images.originals_directory'),
|
|
$pressRelease->id,
|
|
Str::uuid()->toString(),
|
|
$this->extensionFor($upload),
|
|
);
|
|
|
|
$disk = Storage::disk($originalsDisk);
|
|
$stream = fopen($sourcePath, 'rb');
|
|
|
|
if ($stream === false || ! $disk->put($originalPath, $stream)) {
|
|
throw new RuntimeException('Das Original konnte nicht gesichert werden.');
|
|
}
|
|
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
|
|
$stored = null;
|
|
$publishedNow = false;
|
|
|
|
try {
|
|
$provenance = $this->inspector->inspect($disk->path($originalPath));
|
|
|
|
if (! hash_equals($sha256, $provenance->fileSha256)) {
|
|
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, $effective, $precheck, $precheckEvaluation, $allowedStatuses, &$publishedNow): PressReleaseImage {
|
|
$locked = PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->whereKey($pressRelease->getKey())
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
if ($allowedStatuses !== null && ! in_array($locked->status, $allowedStatuses, true)) {
|
|
throw new ImageUploadException(__('Der Status der Meldung hat sich während des Uploads geändert. Bitte laden Sie die Seite neu.'));
|
|
}
|
|
|
|
if ($locked->images()->exists()) {
|
|
throw new ImageUploadException(__('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
|
|
}
|
|
|
|
// Sofort öffentlich ist das Bild nur an einer veröffentlichten
|
|
// Meldung, mit Bestätigung und ohne Rückhalt der Vorab-Sichtung
|
|
// (B-08, N09). Ein Admin-Upload wird es erst mit dem Kundenklick.
|
|
$publishedNow = $locked->status === PressReleaseStatus::Published
|
|
&& $actor->confirmsImmediately()
|
|
&& $precheckEvaluation?->outcome !== ImagePrecheckOutcome::Queue;
|
|
|
|
$image = $locked->images()->create([
|
|
'disk' => $stored['disk'],
|
|
'path' => $stored['path'],
|
|
'variants' => $stored['variants'],
|
|
'title' => $submission->title ?: null,
|
|
'description' => $submission->description ?: null,
|
|
'copyright' => $submission->copyright,
|
|
'credit_source' => $this->creditSource($submission, $actor),
|
|
'license_url' => $submission->licenseUrl ?: null,
|
|
'source_url' => $submission->sourceUrl ?: null,
|
|
'rights_confirmed_at' => $actor->confirmsImmediately() ? now() : null,
|
|
'ai_declaration' => $effective['declaration'],
|
|
'ai_declaration_user' => $submission->aiDeclaration,
|
|
'ai_declaration_source' => $effective['source'],
|
|
'ai_declaration_locked' => $effective['locked'],
|
|
'provenance_status' => $provenance->status,
|
|
'provenance' => $provenance->toArray() + ($effective['deviated'] ? ['deviated_from_prefill' => true] : []),
|
|
'provenance_checked_at' => now(),
|
|
'provenance_tool_version' => $provenance->toolVersion,
|
|
'precheck' => $precheckEvaluation !== null ? $this->precheck->storedPayload($precheck, $precheckEvaluation) : null,
|
|
'precheck_outcome' => $precheckEvaluation?->outcome,
|
|
'precheck_review_required_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
|
|
'precheck_withheld_at' => $precheckEvaluation?->outcome === ImagePrecheckOutcome::Queue ? now() : null,
|
|
'original_disk' => $originalsDisk,
|
|
'original_path' => $originalPath,
|
|
'original_sha256' => $sha256,
|
|
'published_ever_at' => $publishedNow ? now() : null,
|
|
'customer_confirmation_requested_at' => $actor->confirmsImmediately() ? null : now(),
|
|
'rules_version' => config('images.rules_version'),
|
|
'focus_x' => $submission->focus['x'] ?? null,
|
|
'focus_y' => $submission->focus['y'] ?? null,
|
|
'no_crop' => $submission->noCrop,
|
|
'is_preview' => true,
|
|
'sort_order' => 1,
|
|
'width' => $stored['width'],
|
|
'height' => $stored['height'],
|
|
'mime' => $stored['mime'],
|
|
]);
|
|
|
|
if ($precheckEvaluation !== null) {
|
|
$this->precheck->auditStored($image, $precheck, $precheckEvaluation);
|
|
}
|
|
|
|
if ($actor->confirmsImmediately() && $legalText !== null) {
|
|
$this->recordConfirmation($image, $locked, $actor, $legalText, $agbUrl);
|
|
}
|
|
|
|
return $image;
|
|
});
|
|
} catch (Throwable $exception) {
|
|
// Kein Rollback für Dateien: Was geschrieben wurde, wird
|
|
// ausdrücklich wieder entfernt (Feedback F09). Nur Fehler VOR
|
|
// bzw. IM Commit landen hier — danach gehören die Dateien zur
|
|
// gespeicherten Bildzeile (Audit N08).
|
|
if ($stored !== null) {
|
|
$this->imageService->deletePressReleaseImage($stored['disk'], $stored['path'], $stored['variants']);
|
|
}
|
|
|
|
$disk->delete($originalPath);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
// Sofort öffentlich an einer veröffentlichten Meldung (z. B. Admin,
|
|
// der selbst Eigentümer ist, B-09): Snapshot wie beim Veröffentlichen.
|
|
if ($publishedNow) {
|
|
$this->captureSnapshotQuietly($image, $pressRelease);
|
|
}
|
|
|
|
// Öffentliche Seiten-Caches nur verwerfen, wenn die PM schon
|
|
// öffentlich ist (Admin-Bildtausch, D.5) — Entwürfe sind nicht sichtbar.
|
|
if ($pressRelease->published_at !== null) {
|
|
$this->forgetPublicPagesQuietly();
|
|
}
|
|
|
|
return $image;
|
|
}
|
|
|
|
/**
|
|
* Nachträgliche Bestätigung eines bestehenden Bildes durch den
|
|
* Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung
|
|
* der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash.
|
|
* Zusätzlich zur Policy prüft der Service selbst, dass der Bestätiger
|
|
* der Kontoinhaber ist und eine Bestätigung aussteht (Audit N05).
|
|
*
|
|
* Das Bild wird unter Sperre neu geladen (B-14: ein Doppelklick ergibt
|
|
* genau eine Bestätigung, ein inzwischen entferntes Bild keine).
|
|
* `$expectedSha256` bindet die Bestätigung an die Datei, die dem Kunden
|
|
* angezeigt wurde (B-04).
|
|
*/
|
|
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null, ?string $expectedSha256 = null): ImageRightsConfirmation
|
|
{
|
|
$pressRelease = $image->pressRelease;
|
|
|
|
if (
|
|
! $actor->confirmsImmediately()
|
|
|| $pressRelease === null
|
|
|| $actor->user === null
|
|
|| (int) $pressRelease->user_id !== (int) $actor->user->getKey()
|
|
) {
|
|
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
|
|
}
|
|
|
|
$legalText = $this->resolveLegalText(
|
|
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
|
|
$actor,
|
|
);
|
|
|
|
if ($legalText === null) {
|
|
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
|
|
}
|
|
|
|
[$confirmation, $image] = DB::transaction(function () use ($image, $pressRelease, $actor, $legalText, $agbUrl, $expectedSha256): array {
|
|
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
|
|
|
|
if ($locked === null) {
|
|
throw new ImageUploadException(__('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild.'), 'rights_confirmed');
|
|
}
|
|
|
|
if ($expectedSha256 !== null && ($locked->original_sha256 === null || ! hash_equals($expectedSha256, $locked->original_sha256))) {
|
|
throw new ImageUploadException(__('Das Bild wurde inzwischen geändert. Bitte prüfen Sie das aktuelle Titelbild.'), 'rights_confirmed');
|
|
}
|
|
|
|
// Frisch unter der Sperre prüfen: keine Doppelbestätigung, kein
|
|
// Bestätigen eines ersetzten Bildes.
|
|
if (! $locked->awaitsCustomerConfirmation()) {
|
|
throw new ImageUploadException(__('Für dieses Bild steht keine Bestätigung aus.'), 'rights_confirmed');
|
|
}
|
|
|
|
if ($locked->original_sha256 === null) {
|
|
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
|
|
}
|
|
|
|
$confirmation = $this->recordConfirmation($locked, $pressRelease, $actor, $legalText, $agbUrl);
|
|
$locked->forceFill(['rights_confirmed_at' => now()])->save();
|
|
$locked->markPublishedIfNowPublic();
|
|
|
|
return [$confirmation, $locked];
|
|
});
|
|
|
|
// Admin-Bildtausch an einer veröffentlichten PM: Mit der Bestätigung
|
|
// wird das Bild öffentlich — Veröffentlichungssnapshot (E.2) und
|
|
// Seiten-Caches folgen hier, nicht beim Upload.
|
|
if ($pressRelease->status === PressReleaseStatus::Published) {
|
|
$this->captureSnapshotQuietly($image, $pressRelease);
|
|
}
|
|
|
|
if ($pressRelease->published_at !== null) {
|
|
$this->forgetPublicPagesQuietly();
|
|
}
|
|
|
|
return $confirmation;
|
|
}
|
|
|
|
/**
|
|
* Nachgelagerte Schritte nach erfolgreichem Commit dürfen die gespeicherte
|
|
* Bildzeile nicht mehr gefährden: Fehler werden gemeldet, nicht geworfen
|
|
* (Audit N08). Der Cache verfällt spätestens mit seiner TTL.
|
|
*/
|
|
private function forgetPublicPagesQuietly(): void
|
|
{
|
|
try {
|
|
WebCaches::forgetPublicPages();
|
|
} catch (Throwable $exception) {
|
|
report($exception);
|
|
}
|
|
}
|
|
|
|
private function captureSnapshotQuietly(PressReleaseImage $image, PressRelease $pressRelease): void
|
|
{
|
|
try {
|
|
$this->snapshot->capture($image, $pressRelease);
|
|
} catch (Throwable $exception) {
|
|
report($exception);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
|
|
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
|
|
* Ungesicherte Metadaten (Phase 1) belegen nur vor — weicht der Kunde
|
|
* nach unten ab, wird das vermerkt, nicht verhindert.
|
|
*
|
|
* @return array{declaration: AiImageDeclaration|null, source: AiDeclarationSource|null, locked: bool, deviated: bool}
|
|
*/
|
|
private function effectiveDeclaration(?AiImageDeclaration $userChoice, ProvenanceResult $provenance): array
|
|
{
|
|
$enforced = $provenance->enforcedAiDeclaration();
|
|
|
|
if ($enforced !== null) {
|
|
$effective = AiImageDeclaration::strictest($enforced, $userChoice);
|
|
|
|
return [
|
|
'declaration' => $effective,
|
|
'source' => $effective === $userChoice && $userChoice->rank() > $enforced->rank()
|
|
? AiDeclarationSource::Customer
|
|
: AiDeclarationSource::C2paVerified,
|
|
'locked' => true,
|
|
'deviated' => false,
|
|
];
|
|
}
|
|
|
|
$suggestion = $provenance->suggestedAiDeclaration();
|
|
$deviated = $suggestion !== null && $userChoice !== null && $userChoice->rank() < $suggestion->rank();
|
|
|
|
return [
|
|
'declaration' => $userChoice,
|
|
'source' => $userChoice === null
|
|
? null
|
|
: ($suggestion !== null && $suggestion === $userChoice ? AiDeclarationSource::IptcPrefill : AiDeclarationSource::Customer),
|
|
'locked' => false,
|
|
'deviated' => $deviated,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Auswertung des Vorab-Checks (K.2). Die Kundenangabe fließt nur in den
|
|
* Hinweis „wirkt KI-generiert" ein; die Ablehnung hängt allein am Befund.
|
|
*/
|
|
private function assertNotRejected(PressRelease $pressRelease, ImagePrecheckResult $result, ?AiImageDeclaration $userChoice): ImagePrecheckEvaluation
|
|
{
|
|
$evaluation = $this->precheck->evaluate($result, $userChoice);
|
|
|
|
if ($evaluation->outcome === ImagePrecheckOutcome::Reject) {
|
|
$this->precheck->auditRejected($pressRelease, $result, $evaluation);
|
|
|
|
throw new ImageUploadException((string) $evaluation->rejectionMessage());
|
|
}
|
|
|
|
return $evaluation;
|
|
}
|
|
|
|
private function creditSource(ImageSubmission $submission, ImageUploadActor $actor): CreditSource
|
|
{
|
|
if ($submission->creditFromSuggestion) {
|
|
return CreditSource::Metadata;
|
|
}
|
|
|
|
return $actor->channel?->value === 'api' ? CreditSource::Api : CreditSource::Customer;
|
|
}
|
|
|
|
private function resolveLegalText(ImageSubmission $submission, ImageUploadActor $actor): ?LegalText
|
|
{
|
|
if (! $actor->confirmsImmediately()) {
|
|
return null;
|
|
}
|
|
|
|
if (! $submission->rightsConfirmed) {
|
|
throw new ImageUploadException(__('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'), 'rights_confirmed');
|
|
}
|
|
|
|
$current = $this->textRenderer->current();
|
|
|
|
if ($current === null) {
|
|
throw new RuntimeException('Kein aktiver Bestätigungstext hinterlegt (LegalTextSeeder).');
|
|
}
|
|
|
|
if ($submission->legalTextKey !== null && $submission->legalTextKey !== $current->key) {
|
|
throw new ImageUploadException(
|
|
__('Der Bestätigungstext wurde aktualisiert (aktuell :key). Bitte lesen und bestätigen Sie die neue Fassung.', ['key' => $current->key]),
|
|
'rights_confirmation_text_version',
|
|
);
|
|
}
|
|
|
|
return $current;
|
|
}
|
|
|
|
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): ImageRightsConfirmation
|
|
{
|
|
$variables = $this->textRenderer->variables($agbUrl);
|
|
|
|
return ImageRightsConfirmation::query()->create([
|
|
'subject_type' => $image->getMorphClass(),
|
|
'subject_id' => $image->getKey(),
|
|
'press_release_id' => $pressRelease->getKey(),
|
|
'company_id' => $pressRelease->company_id,
|
|
'user_id' => $actor->user?->getKey(),
|
|
'personal_access_token_id' => $actor->tokenId,
|
|
'channel' => $actor->channel,
|
|
'legal_text_id' => $legalText->getKey(),
|
|
'legal_text_key' => $legalText->key,
|
|
'text_snapshot' => $legalText->render($variables),
|
|
'agb_version' => $variables['agb_version'],
|
|
'locale' => $legalText->locale,
|
|
'file_sha256' => $image->original_sha256,
|
|
'declaration_snapshot' => [
|
|
'copyright' => $image->copyright,
|
|
'credit_source' => $image->credit_source?->value,
|
|
'license_url' => $image->license_url,
|
|
'source_url' => $image->source_url,
|
|
'ai_declaration_user' => $image->ai_declaration_user?->value,
|
|
'ai_declaration' => $image->ai_declaration?->value,
|
|
'ai_declaration_source' => $image->ai_declaration_source?->value,
|
|
'locked' => $image->ai_declaration_locked,
|
|
'provenance_status' => $image->provenance_status?->value,
|
|
'provenance' => $image->provenance,
|
|
],
|
|
'precheck_snapshot' => $image->precheck,
|
|
'ip_hmac' => ImageRightsConfirmation::hashIp($actor->ip),
|
|
'user_agent' => $actor->userAgent !== null ? Str::limit($actor->userAgent, 250, '') : null,
|
|
'confirmed_at' => now(),
|
|
'created_at' => now(),
|
|
]);
|
|
}
|
|
|
|
private function assertWithinLimits(UploadedFile $upload): void
|
|
{
|
|
$maxBytes = (int) config('images.max_bytes');
|
|
|
|
if ($upload->getSize() > $maxBytes) {
|
|
throw new ImageUploadException(__('Das Bild ist größer als :mb MB.', ['mb' => (int) round($maxBytes / 1024 / 1024)]));
|
|
}
|
|
|
|
if (! in_array($upload->getMimeType(), ImageService::ALLOWED_PRESS_RELEASE_IMAGE_MIME_TYPES, true)) {
|
|
throw new ImageUploadException(__('Bitte laden Sie ein JPG-, PNG- oder WebP-Bild hoch.'));
|
|
}
|
|
|
|
$size = @getimagesize($upload->getRealPath());
|
|
$maxSide = (int) config('images.max_pixels_per_side');
|
|
|
|
if ($size === false) {
|
|
throw new ImageUploadException(__('Die Datei konnte nicht als Bild gelesen werden.'));
|
|
}
|
|
|
|
if ($size[0] > $maxSide || $size[1] > $maxSide) {
|
|
throw new ImageUploadException(__('Das Bild darf höchstens :px Pixel je Seite haben.', ['px' => number_format($maxSide, 0, ',', '.')]));
|
|
}
|
|
}
|
|
|
|
private function extensionFor(UploadedFile $upload): string
|
|
{
|
|
return match ($upload->getMimeType()) {
|
|
'image/png' => 'png',
|
|
'image/webp' => 'webp',
|
|
default => 'jpg',
|
|
};
|
|
}
|
|
}
|