images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)
- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung) - deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1) - press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard) - Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien - ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite - Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9f44546082
commit
da167b713b
24 changed files with 1202 additions and 12 deletions
62
app/Console/Commands/BackfillPublishedEver.php
Normal file
62
app/Console/Commands/BackfillPublishedEver.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung E.1: `published_ever_at` für den Bestand — jede
|
||||
* Meldung, die heute veröffentlicht ist, ein `published_at` trägt oder
|
||||
* laut Status-Log jemals auf `published` stand, war öffentlich. Ihre Bilder
|
||||
* erben den Zeitpunkt. Idempotent, nur NULL-Zeilen werden gesetzt.
|
||||
*/
|
||||
class BackfillPublishedEver extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:backfill-published-ever {--dry-run : Nur zählen}';
|
||||
|
||||
protected $description = 'Setzt published_ever_at für Meldungen und Bilder, die jemals öffentlich waren';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$candidates = PressRelease::withoutGlobalScopes()
|
||||
->whereNull('published_ever_at')
|
||||
->where(function ($query): void {
|
||||
$query->whereNotNull('published_at')
|
||||
->orWhere('status', PressReleaseStatus::Published->value)
|
||||
->orWhereIn('id', PressReleaseStatusLog::query()->where('to_status', PressReleaseStatus::Published->value)->select('press_release_id'));
|
||||
});
|
||||
|
||||
$count = $candidates->count();
|
||||
$this->info("Meldungen ohne published_ever_at, die öffentlich waren: {$count}");
|
||||
|
||||
if ($this->option('dry-run') || $count === 0) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
|
||||
$candidates->select(['id', 'published_at', 'created_at'])->chunkById(1000, function ($releases) use (&$updated): void {
|
||||
foreach ($releases as $release) {
|
||||
$firstLog = PressReleaseStatusLog::query()
|
||||
->where('press_release_id', $release->id)
|
||||
->where('to_status', PressReleaseStatus::Published->value)
|
||||
->min('created_at');
|
||||
|
||||
$at = collect([$release->published_at, $firstLog, $release->created_at])->filter()->min();
|
||||
|
||||
DB::table('press_releases')->where('id', $release->id)->update(['published_ever_at' => $at]);
|
||||
DB::table('press_release_images')->where('press_release_id', $release->id)->whereNull('published_ever_at')
|
||||
->update(['published_ever_at' => DB::raw('CASE WHEN created_at > '.DB::getPdo()->quote((string) $at).' THEN created_at ELSE '.DB::getPdo()->quote((string) $at).' END')]);
|
||||
$updated++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Gesetzt: {$updated} Meldungen samt Bildern.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
|
|
@ -36,9 +37,23 @@ class PurgeExpiredPressReleaseDrafts extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Bilder nie veröffentlichter Entwürfe werden mit dem Purge sofort
|
||||
// und vollständig gelöscht (Bildkennzeichnung E.3, Decision-Update E4).
|
||||
$remover = app(PressReleaseImageRemover::class);
|
||||
$imagesRemoved = 0;
|
||||
|
||||
(clone $query)->whereNull('published_ever_at')->with('images')->chunkById(200, function ($drafts) use ($remover, &$imagesRemoved): void {
|
||||
foreach ($drafts as $draft) {
|
||||
foreach ($draft->images as $image) {
|
||||
$remover->remove($image);
|
||||
$imagesRemoved++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$query->update(['status' => PressReleaseStatus::Archived->value]);
|
||||
|
||||
$this->info("PM-Entwürfe archiviert: {$count} Einträge.");
|
||||
$this->info("PM-Entwürfe archiviert: {$count} Einträge, Bilder gelöscht: {$imagesRemoved}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Legacy-Bereinigung (Bildkennzeichnung E.5, Entscheidung E-B11): Bilder
|
||||
* aus dem Alt-Import an Meldungen, die nicht veröffentlicht sind (offline
|
||||
* genommen, archiviert, Entwurf), werden samt Dateien gelöscht. Sie haben
|
||||
* weder Nachweis noch Protokoll; eine erneute Einreichung läuft ohne Bild.
|
||||
* Auf der Dev-Kopie: 10.768 Bilder (Stand 22.09.2026).
|
||||
*/
|
||||
class PurgeInactiveLegacyPressReleaseImages extends Command
|
||||
{
|
||||
protected $signature = 'press-release-images:purge-inactive-legacy
|
||||
{--execute : Wirklich löschen (Standard ist ein Trockenlauf)}
|
||||
{--chunk=500 : Zeilen je Durchlauf}';
|
||||
|
||||
protected $description = 'Löscht Legacy-Bilder an nicht veröffentlichten Meldungen samt Dateien (E-B11)';
|
||||
|
||||
public function __construct(private readonly ImageService $imageService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$execute = (bool) $this->option('execute');
|
||||
$inactive = [PressReleaseStatus::Rejected->value, PressReleaseStatus::Archived->value, PressReleaseStatus::Draft->value];
|
||||
|
||||
$query = PressReleaseImage::query()
|
||||
->whereNotNull('press_release_images.legacy_id')
|
||||
->whereNull('press_release_images.rules_version')
|
||||
->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScopes()->whereIn('press_releases.status', $inactive));
|
||||
|
||||
$byStatus = (clone $query)
|
||||
->join('press_releases', 'press_releases.id', '=', 'press_release_images.press_release_id')
|
||||
->selectRaw('press_releases.status as status, count(*) as c')
|
||||
->groupBy('press_releases.status')
|
||||
->pluck('c', 'status')
|
||||
->all();
|
||||
|
||||
$total = array_sum($byStatus);
|
||||
$this->info(($execute ? 'Lösche' : 'Trockenlauf:').' Legacy-Bilder an inaktiven Meldungen: '.$total);
|
||||
|
||||
foreach ($byStatus as $status => $count) {
|
||||
$this->line(sprintf(' %-10s %d', $status, $count));
|
||||
}
|
||||
|
||||
if (! $execute || $total === 0) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$bytes = 0;
|
||||
|
||||
$query->with('pressRelease')->chunkById((int) $this->option('chunk'), function ($images) use (&$deleted, &$bytes): void {
|
||||
foreach ($images as $image) {
|
||||
$bytes += $this->sizeOf($image);
|
||||
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||
$image->forceDelete();
|
||||
$deleted++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('Gelöscht: %d Bilder, etwa %s MB freigegeben.', $deleted, number_format($bytes / 1048576, 1, ',', '.')));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sizeOf(PressReleaseImage $image): int
|
||||
{
|
||||
$disk = Storage::disk($image->disk);
|
||||
$size = 0;
|
||||
|
||||
foreach (array_filter([$image->path, ...array_values($image->variants ?? [])]) as $path) {
|
||||
if (is_string($path) && $disk->exists($path)) {
|
||||
$size += (int) $disk->size($path);
|
||||
}
|
||||
}
|
||||
|
||||
return $size;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,12 @@ enum ActivityType: string
|
|||
case MailSuppressed = 'mail_suppressed';
|
||||
case ManualInvoice = 'manual_invoice';
|
||||
|
||||
/** Zugriff auf Beweisoriginal oder veröffentlichte Bildfassung (Bildkennzeichnung E.4). */
|
||||
case EvidenceAccess = 'evidence_access';
|
||||
|
||||
/** Korrekturweg Bildnachweis/Kennzeichnung (Bildkennzeichnung E.6). */
|
||||
case ImageCorrection = 'image_correction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -55,6 +61,8 @@ enum ActivityType: string
|
|||
self::ContentRemoval => 'Entfernung (410)',
|
||||
self::MailSuppressed => 'Mail-Sperre',
|
||||
self::ManualInvoice => 'Rechnung (MAN)',
|
||||
self::EvidenceAccess => 'Beweiszugriff',
|
||||
self::ImageCorrection => 'Bildkorrektur',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +86,8 @@ enum ActivityType: string
|
|||
self::Anonymization => 'red',
|
||||
self::Correction => 'blue',
|
||||
self::ContentRemoval => 'red',
|
||||
self::EvidenceAccess => 'red',
|
||||
self::ImageCorrection => 'blue',
|
||||
self::MailSuppressed => 'orange',
|
||||
self::ManualInvoice => 'amber',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\ActivityLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Einziger Zugriffsweg auf Beweisoriginal und veröffentlichte Fassung
|
||||
* (Bildkennzeichnung E.4): nur Admins, nur mit angegebenem Zweck, jeder
|
||||
* Abruf im Aktivitätsverlauf. Medienroute, Vorschau-Signaturen und API
|
||||
* kennen diese Pfade nicht. Block F verlagert das Protokoll in
|
||||
* `archive_access_log`.
|
||||
*/
|
||||
class PressReleaseImageEvidenceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ActivityLog $activityLog) {}
|
||||
|
||||
public function original(Request $request, int $id, int $image): StreamedResponse
|
||||
{
|
||||
return $this->stream($request, $id, $image, 'original');
|
||||
}
|
||||
|
||||
public function published(Request $request, int $id, int $image): StreamedResponse
|
||||
{
|
||||
return $this->stream($request, $id, $image, 'published');
|
||||
}
|
||||
|
||||
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse
|
||||
{
|
||||
$purpose = trim((string) $request->query('purpose', ''));
|
||||
|
||||
abort_if($purpose === '', 422, 'Zweck des Zugriffs fehlt (purpose).');
|
||||
|
||||
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
|
||||
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
|
||||
|
||||
$path = $kind === 'original' ? $image->original_path : $image->published_snapshot_path;
|
||||
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
|
||||
abort_unless(is_string($path) && $path !== '' && $disk->exists($path), 404);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::EvidenceAccess,
|
||||
sprintf('%s zu Bild #%d (PM #%d) abgerufen: %s', $kind === 'original' ? 'Beweisoriginal' : 'Veröffentlichte Fassung', $image->getKey(), $pressRelease->getKey(), $purpose),
|
||||
$request->user(),
|
||||
$image,
|
||||
['kind' => $kind, 'purpose' => $purpose, 'sha256' => $kind === 'original' ? $image->original_sha256 : $image->published_snapshot_sha256],
|
||||
);
|
||||
|
||||
return $disk->response($path, basename($path), [
|
||||
'Cache-Control' => 'private, no-store',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
app/Models/ImageDeclarationCorrection.php
Normal file
62
app/Models/ImageDeclarationCorrection.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Insert-only-Protokoll des Korrekturwegs (Bildkennzeichnung E.6): welcher
|
||||
* Admin wann welches Feld eines Bildes mit welchem Grund geändert hat.
|
||||
*/
|
||||
class ImageDeclarationCorrection extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
public const FIELD_CREDIT = 'copyright';
|
||||
|
||||
public const FIELD_AI_DECLARATION = 'ai_declaration';
|
||||
|
||||
public const FIELD_WITHHELD = 'withheld';
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_image_id',
|
||||
'press_release_id',
|
||||
'admin_id',
|
||||
'field',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'old_source',
|
||||
'reason',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new \LogicException('image_declaration_corrections ist insert-only.'));
|
||||
static::deleting(fn () => throw new \LogicException('image_declaration_corrections wird nicht gelöscht.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<PressReleaseImage, $this>
|
||||
*/
|
||||
public function image(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressReleaseImage::class, 'press_release_image_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function admin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'admin_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,7 @@ class PressRelease extends Model
|
|||
'no_export',
|
||||
'published_at',
|
||||
'depublished_at',
|
||||
'published_ever_at',
|
||||
'gone_at',
|
||||
'no_export_before_gone',
|
||||
'scheduled_at',
|
||||
|
|
@ -134,6 +135,7 @@ class PressRelease extends Model
|
|||
'no_export' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'depublished_at' => 'datetime',
|
||||
'published_ever_at' => 'datetime',
|
||||
'gone_at' => 'datetime',
|
||||
'no_export_before_gone' => 'boolean',
|
||||
'sync_locked_at' => 'datetime',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Enums\CreditSource;
|
|||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\ImageProvenanceStatus;
|
||||
use Database\Factories\PressReleaseImageFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -60,6 +61,7 @@ use Illuminate\Support\Facades\URL;
|
|||
* @property string|null $published_snapshot_sha256
|
||||
* @property Carbon|null $published_ever_at
|
||||
* @property Carbon|null $customer_confirmation_requested_at
|
||||
* @property Carbon|null $withheld_at
|
||||
* @property string|null $rules_version
|
||||
* @property int|null $focus_x
|
||||
* @property int|null $focus_y
|
||||
|
|
@ -116,6 +118,7 @@ class PressReleaseImage extends Model
|
|||
'published_snapshot_sha256',
|
||||
'published_ever_at',
|
||||
'customer_confirmation_requested_at',
|
||||
'withheld_at',
|
||||
'rules_version',
|
||||
'focus_x',
|
||||
'focus_y',
|
||||
|
|
@ -147,6 +150,7 @@ class PressReleaseImage extends Model
|
|||
'provenance_checked_at' => 'datetime',
|
||||
'published_ever_at' => 'datetime',
|
||||
'customer_confirmation_requested_at' => 'datetime',
|
||||
'withheld_at' => 'datetime',
|
||||
'focus_x' => 'integer',
|
||||
'focus_y' => 'integer',
|
||||
'no_crop' => 'boolean',
|
||||
|
|
@ -220,6 +224,26 @@ class PressReleaseImage extends Model
|
|||
return $this->published_ever_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorläufig zurückgehalten (Korrekturweg E.6): öffentlich greift der
|
||||
* Platzhalter, im Panel bleibt das Bild sichtbar.
|
||||
*/
|
||||
public function isWithheld(): bool
|
||||
{
|
||||
return $this->withheld_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentlich ausspielbare Bilder: nicht zurückgehalten.
|
||||
*
|
||||
* @param Builder<PressReleaseImage> $query
|
||||
* @return Builder<PressReleaseImage>
|
||||
*/
|
||||
public function scopePubliclyVisible(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('withheld_at');
|
||||
}
|
||||
|
||||
public function url(bool $signed = false): ?string
|
||||
{
|
||||
if (blank($this->path)) {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,23 @@ class AdminSystemHealth
|
|||
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
|
||||
];
|
||||
|
||||
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
||||
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
||||
// nichts, muss aber sichtbar bleiben.
|
||||
$provenanceFailures = PressReleaseImage::query()
|
||||
->withTrashed()
|
||||
->where('provenance_status', 'error')
|
||||
->where('provenance_checked_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
$checks[] = [
|
||||
'key' => 'image_provenance',
|
||||
'label' => 'Herkunftsprüfung Bilder',
|
||||
'status' => $provenanceFailures === 0 ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $provenanceFailures === 0
|
||||
? 'Keine fehlgeschlagene Herkunftsprüfung in den letzten 7 Tagen.'
|
||||
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
|
||||
];
|
||||
|
||||
// Ausstehende Medien-Privatisierung.
|
||||
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
|
||||
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Veröffentlichungsgate (Bildkennzeichnung E.1): Ein Bild aus Formular v2
|
||||
* ohne gültige Rechtebestätigung zur aktuellen Datei — typisch ein
|
||||
* Admin-Upload, den der Kunde noch nicht bestätigt hat (E10).
|
||||
*/
|
||||
class ImageRightsConfirmationRequiredException extends RuntimeException
|
||||
{
|
||||
public function __construct(?string $message = null)
|
||||
{
|
||||
parent::__construct($message ?? __('Das Titelbild ist noch nicht vom Kontoinhaber bestätigt. Bis dahin kann die Meldung nicht mit Bild veröffentlicht werden.'));
|
||||
}
|
||||
}
|
||||
82
app/Services/PressRelease/Images/ImageCorrectionService.php
Normal file
82
app/Services/PressRelease/Images/ImageCorrectionService.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Korrekturweg (Bildkennzeichnung E.6, Decision-Update S17): Der Admin
|
||||
* ändert Bildnachweis oder Portalkennzeichnung mit Pflichtgrund oder hält
|
||||
* das Bild vorläufig zurück. Kundenangabe, ursprüngliche Bestätigung und
|
||||
* Befund bleiben unverändert; jede Änderung ist insert-only protokolliert.
|
||||
*/
|
||||
class ImageCorrectionService
|
||||
{
|
||||
public function __construct(private readonly PublishedImageSnapshot $snapshots) {}
|
||||
|
||||
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_CREDIT, $image->copyright, $newCredit, null, $reason, function () use ($image, $newCredit): void {
|
||||
$image->forceFill(['copyright' => $newCredit])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function correctAiDeclaration(PressReleaseImage $image, User $admin, AiImageDeclaration $new, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_AI_DECLARATION, $image->ai_declaration?->value, $new->value, $image->ai_declaration_source?->value, $reason, function () use ($image, $new): void {
|
||||
$image->forceFill([
|
||||
'ai_declaration' => $new,
|
||||
'ai_declaration_source' => AiDeclarationSource::AdminCorrection,
|
||||
'ai_declaration_locked' => false,
|
||||
])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function withhold(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '0', '1', null, $reason, function () use ($image): void {
|
||||
$image->forceFill(['withheld_at' => now()])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function release(PressReleaseImage $image, User $admin, string $reason): ImageDeclarationCorrection
|
||||
{
|
||||
return $this->apply($image, $admin, ImageDeclarationCorrection::FIELD_WITHHELD, '1', '0', null, $reason, function () use ($image): void {
|
||||
$image->forceFill(['withheld_at' => null])->save();
|
||||
});
|
||||
}
|
||||
|
||||
private function apply(PressReleaseImage $image, User $admin, string $field, ?string $old, ?string $new, ?string $oldSource, string $reason, \Closure $mutation): ImageDeclarationCorrection
|
||||
{
|
||||
$correction = DB::transaction(function () use ($image, $admin, $field, $old, $new, $oldSource, $reason, $mutation): ImageDeclarationCorrection {
|
||||
$mutation();
|
||||
|
||||
return ImageDeclarationCorrection::query()->create([
|
||||
'press_release_image_id' => $image->getKey(),
|
||||
'press_release_id' => $image->press_release_id,
|
||||
'admin_id' => $admin->getKey(),
|
||||
'field' => $field,
|
||||
'old_value' => $old,
|
||||
'new_value' => $new,
|
||||
'old_source' => $oldSource,
|
||||
'reason' => $reason,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
|
||||
// Seiten-Caches verwerfen (D.5).
|
||||
if ($image->wasEverPublished()) {
|
||||
$this->snapshots->capture($image->fresh());
|
||||
WebCaches::forgetPublicPages();
|
||||
}
|
||||
|
||||
return $correction;
|
||||
}
|
||||
}
|
||||
85
app/Services/PressRelease/Images/PublishedImageSnapshot.php
Normal file
85
app/Services/PressRelease/Images/PublishedImageSnapshot.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease\Images;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Support\ImageCredit;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Snapshot der veröffentlichten Bildfassung (Bildkennzeichnung E.2,
|
||||
* Decision-Update E2/S1): beim Veröffentlichen wird die ausgelieferte
|
||||
* Cover-Variante zusammen mit dem gerenderten Nachweis und der Kennzeichnung
|
||||
* auf den privaten Snapshot-Pfad kopiert. So lässt sich später belegen, was
|
||||
* mit welchem Nachweis öffentlich war — auch nach Korrektur oder Entfernung.
|
||||
*/
|
||||
class PublishedImageSnapshot
|
||||
{
|
||||
public function captureForPressRelease(PressRelease $pressRelease): void
|
||||
{
|
||||
$pressRelease->loadMissing('company');
|
||||
|
||||
foreach ($pressRelease->images()->whereNotNull('rules_version')->get() as $image) {
|
||||
$this->capture($image, $pressRelease);
|
||||
}
|
||||
}
|
||||
|
||||
public function capture(PressReleaseImage $image, ?PressRelease $pressRelease = null): void
|
||||
{
|
||||
$pressRelease ??= $image->pressRelease;
|
||||
|
||||
if ($pressRelease === null || $image->isLegacyRules() || $image->original_sha256 === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceDisk = Storage::disk($image->disk);
|
||||
$coverPath = $image->pathForVariant('cover') ?? $image->path;
|
||||
|
||||
if (! $sourceDisk->exists($coverPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = (string) $sourceDisk->get($coverPath);
|
||||
$sha = hash('sha256', $bytes);
|
||||
|
||||
// Unverändert seit dem letzten Snapshot: nichts zu tun.
|
||||
if ($image->published_snapshot_sha256 !== null && hash_equals($image->published_snapshot_sha256, $sha) && $image->published_snapshot_path !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$credit = ImageCredit::for($image, $pressRelease->company);
|
||||
$extension = pathinfo($coverPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$base = sprintf(
|
||||
'%s/%d/%d-%s',
|
||||
config('images.published_snapshot_directory'),
|
||||
$pressRelease->getKey(),
|
||||
$image->getKey(),
|
||||
substr($sha, 0, 12),
|
||||
);
|
||||
|
||||
$targetDisk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
||||
$targetDisk->put($base.'.'.$extension, $bytes);
|
||||
$targetDisk->put($base.'.json', (string) json_encode([
|
||||
'captured_at' => now()->toIso8601String(),
|
||||
'press_release_id' => $pressRelease->getKey(),
|
||||
'image_id' => $image->getKey(),
|
||||
'variant' => 'cover',
|
||||
'variant_sha256' => $sha,
|
||||
'original_sha256' => $image->original_sha256,
|
||||
'credit_line' => $credit->line(),
|
||||
'credit' => $credit->credit,
|
||||
'ai_declaration' => $image->ai_declaration?->value,
|
||||
'ai_declaration_source' => $image->ai_declaration_source?->value,
|
||||
'responsible_line' => $credit->responsibleLine,
|
||||
'confirmation_key' => $credit->confirmation?->legal_text_key,
|
||||
'confirmation_at' => $credit->confirmation?->confirmed_at?->toIso8601String(),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$image->forceFill([
|
||||
'published_snapshot_path' => $base.'.'.$extension,
|
||||
'published_snapshot_sha256' => $sha,
|
||||
'published_ever_at' => $image->published_ever_at ?? now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ use App\Scopes\PortalScope;
|
|||
use App\Services\ActivityLog;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\PressRelease\Images\PublishedImageSnapshot;
|
||||
use App\Support\WebCaches;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -330,13 +332,20 @@ class PressReleaseService
|
|||
return ['blacklist' => ['reason' => $reason, 'word' => $word]];
|
||||
}
|
||||
|
||||
// Veröffentlichungsgate (Bildkennzeichnung E.1): kein Bild aus
|
||||
// Formular v2 ohne gültige Bestätigung zur aktuellen Datei.
|
||||
$this->assertImageRightsConfirmed($locked);
|
||||
|
||||
$this->consumePublishSlot($locked);
|
||||
|
||||
$locked->update([
|
||||
'status' => PressReleaseStatus::Published->value,
|
||||
'published_at' => $this->resolvePublishedAt($locked, $publishedAtOverride),
|
||||
'published_ever_at' => $locked->published_ever_at ?? now(),
|
||||
]);
|
||||
|
||||
$this->markImagesPublished($locked);
|
||||
|
||||
$this->logStatusChange($locked, $previous, PressReleaseStatus::Published, null, $source);
|
||||
|
||||
return null;
|
||||
|
|
@ -344,6 +353,10 @@ class PressReleaseService
|
|||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($outcome === null) {
|
||||
$this->capturePublishedImages($pressRelease);
|
||||
}
|
||||
|
||||
if (isset($outcome['stale'])) {
|
||||
ClassifyPressRelease::dispatch($pressRelease->id, route: true, contentHash: $outcome['stale']['current'])->onQueue('classification');
|
||||
|
||||
|
|
@ -586,6 +599,7 @@ class PressReleaseService
|
|||
$previous = $locked->status;
|
||||
|
||||
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
|
||||
$this->assertImageRightsConfirmed($locked);
|
||||
$this->consumePublishSlot($locked);
|
||||
}
|
||||
|
||||
|
|
@ -594,8 +608,15 @@ class PressReleaseService
|
|||
'published_at' => $status === PressReleaseStatus::Published
|
||||
? ($locked->published_at ?? now())
|
||||
: $locked->published_at,
|
||||
'published_ever_at' => $status === PressReleaseStatus::Published
|
||||
? ($locked->published_ever_at ?? now())
|
||||
: $locked->published_ever_at,
|
||||
]);
|
||||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
$this->markImagesPublished($locked);
|
||||
}
|
||||
|
||||
if ($previous !== $status) {
|
||||
$this->logStatusChange($locked, $previous, $status, $reason, 'admin');
|
||||
}
|
||||
|
|
@ -606,6 +627,10 @@ class PressReleaseService
|
|||
});
|
||||
|
||||
$pressRelease->refresh();
|
||||
|
||||
if ($status === PressReleaseStatus::Published) {
|
||||
$this->capturePublishedImages($pressRelease);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteFromAdmin(PressRelease $pressRelease): void
|
||||
|
|
@ -626,9 +651,48 @@ class PressReleaseService
|
|||
return;
|
||||
}
|
||||
|
||||
// Nie veröffentlicht: Bilder samt Original und Bestätigung sofort
|
||||
// weg (Bildkennzeichnung E.3, Decision-Update E4).
|
||||
$remover = app(PressReleaseImageRemover::class);
|
||||
|
||||
foreach ($pressRelease->images()->get() as $image) {
|
||||
$remover->remove($image);
|
||||
}
|
||||
|
||||
$pressRelease->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichungsgate (E.1): Bilder aus Formular v2 brauchen eine
|
||||
* Bestätigung zur aktuellen Datei — sonst wartet ein Admin-Upload noch
|
||||
* auf den Kunden (E10). Bestandsbilder sind nicht betroffen.
|
||||
*/
|
||||
private function assertImageRightsConfirmed(PressRelease $pressRelease): void
|
||||
{
|
||||
$unconfirmed = $pressRelease->images()
|
||||
->whereNotNull('rules_version')
|
||||
->with('latestRightsConfirmation')
|
||||
->get()
|
||||
->first(fn (PressReleaseImage $image): bool => ! $image->hasValidRightsConfirmation());
|
||||
|
||||
if ($unconfirmed !== null) {
|
||||
throw new ImageRightsConfirmationRequiredException;
|
||||
}
|
||||
}
|
||||
|
||||
private function markImagesPublished(PressRelease $pressRelease): void
|
||||
{
|
||||
$pressRelease->images()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot der veröffentlichten Bildfassung nach dem Commit (E.2).
|
||||
*/
|
||||
private function capturePublishedImages(PressRelease $pressRelease): void
|
||||
{
|
||||
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernung mit 410 Gone: die Mitteilung verschwindet aus allen
|
||||
* öffentlichen Listen, ihre Detail-URL liefert aber weiterhin einen
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class CategoryPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
// Gebuchte Boosts (Decision-Update §2.2): geboostete PMs der Branche
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class HomePageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query
|
||||
'images' => fn ($query) => $query->publiclyVisible()
|
||||
->orderByDesc('is_preview')
|
||||
->orderBy('sort_order')
|
||||
->limit(1),
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class NewsroomDetailPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
$leadRelease = $base()->with($with)->orderByDesc('published_at')->first();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class ReleaseDetailPageData
|
|||
'user:id,agency_name',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'category.parent.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order'),
|
||||
'contacts',
|
||||
])
|
||||
// Doppelveröffentlichung mit gleichem Slug: dieselbe Wahl wie
|
||||
|
|
@ -107,7 +107,7 @@ class ReleaseDetailPageData
|
|||
$with = [
|
||||
'company',
|
||||
'category.translations' => fn ($query) => $query->where('locale', $language),
|
||||
'images' => fn ($query) => $query->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
'images' => fn ($query) => $query->publiclyVisible()->orderByDesc('is_preview')->orderBy('sort_order')->limit(1),
|
||||
];
|
||||
|
||||
$companyReleases = $release->company_id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung E.1 (Decision-Update S8, E5): „veröffentlicht" heißt,
|
||||
* die Meldung hatte irgendwann eine öffentlich abrufbare URL — auch kurz.
|
||||
* Der Zeitpunkt entscheidet, ob Bilder beim Entfernen ins Beweisarchiv
|
||||
* gehen (drei Jahre) oder sofort gelöscht werden (nie veröffentlicht).
|
||||
*
|
||||
* `published_at` reicht dafür nicht: Es wird bei Terminierung überschrieben
|
||||
* und bleibt nach Depublikation stehen, sagt aber nichts über Bilder, die
|
||||
* später hinzukamen. Backfill über press-releases:backfill-published-ever.
|
||||
*
|
||||
* press_releases hat keinen FULLTEXT-Index (Suchtabelle getrennt), ADD
|
||||
* COLUMN läuft inplace.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->timestamp('published_ever_at')->nullable()->after('published_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->dropColumn('published_ever_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung E.6 (Decision-Update S17): Zwischenzustand „Bild
|
||||
* vorläufig zurückgehalten" während ein Einwand gegen Nachweis oder
|
||||
* Kennzeichnung geprüft wird. Gesetzt: Bild verschwindet aus der
|
||||
* öffentlichen Seite, der Platzhalter greift; die Meldung bleibt online.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_release_images', function (Blueprint $table) {
|
||||
$table->timestamp('withheld_at')->nullable()->after('customer_confirmation_requested_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('press_release_images', function (Blueprint $table) {
|
||||
$table->dropColumn('withheld_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Korrekturweg (Bildkennzeichnung E.6, Decision-Update S17): Ein Admin
|
||||
* ändert Bildnachweis oder Portalkennzeichnung mit Pflichtgrund. Alt/neu,
|
||||
* Grund und Bearbeiter bleiben insert-only erhalten; die ursprüngliche
|
||||
* Bestätigung und der ursprüngliche Befund werden nicht angefasst. Block F
|
||||
* spiegelt jede Korrektur zusätzlich als Archiveintrag `label_corrected`.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('image_declaration_corrections', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('press_release_image_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('press_release_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('admin_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('field', 40);
|
||||
$table->text('old_value')->nullable();
|
||||
$table->text('new_value')->nullable();
|
||||
$table->string('old_source', 30)->nullable();
|
||||
$table->string('reason', 1000);
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['press_release_image_id', 'created_at'], 'idc_image_created_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('image_declaration_corrections');
|
||||
}
|
||||
};
|
||||
|
|
@ -564,12 +564,6 @@ parameters:
|
|||
count: 1
|
||||
path: app/Models/PressRelease.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @var with type Illuminate\\Database\\Eloquent\\Model is not subtype of native type \$this\(App\\Models\\PressRelease\)\.$#'
|
||||
identifier: varTag.nativeType
|
||||
count: 1
|
||||
path: app/Models/PressRelease.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Database\\Eloquent\\Collection\<int, App\\Models\\PressReleaseImage\>\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -30,6 +33,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (ImageRightsConfirmationRequiredException $e) {
|
||||
Flux::toast(heading: __('Titelbild nicht bestätigt'), text: $e->getMessage(), variant: 'danger', duration: 8000);
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
} catch (BlacklistViolationException $e) {
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
|
|
@ -69,6 +77,77 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
Flux::modal('confirm-show-archive')->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder
|
||||
* Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten.
|
||||
*/
|
||||
public string $correctionCredit = '';
|
||||
|
||||
public string $correctionAiDeclaration = '';
|
||||
|
||||
public string $correctionReason = '';
|
||||
|
||||
public function correctImage(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
if ($image === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'correctionReason' => ['required', 'string', 'min:5', 'max:1000'],
|
||||
'correctionCredit' => ['nullable', 'string', 'max:255'],
|
||||
'correctionAiDeclaration' => ['nullable', \Illuminate\Validation\Rule::enum(AiImageDeclaration::class)],
|
||||
], [
|
||||
'correctionReason.required' => __('Bitte geben Sie den Grund der Korrektur an.'),
|
||||
]);
|
||||
|
||||
$admin = auth()->user();
|
||||
$changed = 0;
|
||||
|
||||
if ($this->correctionCredit !== '' && $this->correctionCredit !== (string) $image->copyright) {
|
||||
$corrections->correctCredit($image, $admin, trim($this->correctionCredit), $this->correctionReason);
|
||||
$changed++;
|
||||
}
|
||||
|
||||
if ($this->correctionAiDeclaration !== '' && $this->correctionAiDeclaration !== $image->ai_declaration?->value) {
|
||||
$corrections->correctAiDeclaration($image, $admin, AiImageDeclaration::from($this->correctionAiDeclaration), $this->correctionReason);
|
||||
$changed++;
|
||||
}
|
||||
|
||||
$this->reset(['correctionCredit', 'correctionAiDeclaration', 'correctionReason']);
|
||||
|
||||
Flux::toast(
|
||||
text: $changed > 0 ? __('Korrektur protokolliert und übernommen.') : __('Keine Änderung: Die Angaben entsprechen dem aktuellen Stand.'),
|
||||
variant: $changed > 0 ? 'success' : 'warning',
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleWithheld(ImageCorrectionService $corrections): void
|
||||
{
|
||||
$pr = $this->findAdminPR($this->id);
|
||||
$image = $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->first();
|
||||
|
||||
if ($image === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate(
|
||||
['correctionReason' => ['required', 'string', 'min:5', 'max:1000']],
|
||||
['correctionReason.required' => __('Bitte geben Sie den Grund an.')],
|
||||
);
|
||||
|
||||
$image->isWithheld()
|
||||
? $corrections->release($image, auth()->user(), $this->correctionReason)
|
||||
: $corrections->withhold($image, auth()->user(), $this->correctionReason);
|
||||
|
||||
$this->reset(['correctionReason']);
|
||||
|
||||
Flux::toast(text: $image->fresh()->isWithheld() ? __('Bild vorläufig zurückgehalten.') : __('Bild wieder freigegeben.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()
|
||||
|
|
@ -238,6 +317,46 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@endif
|
||||
</article>
|
||||
|
||||
{{-- ============== KORREKTURWEG BILD (E.6) ============== --}}
|
||||
@if (! $coverIsPlaceholder && isset($adminTitleImage) && $adminTitleImage && ! $adminTitleImage->isLegacyRules())
|
||||
<article class="panel" data-image-correction>
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Bildnachweis und Kennzeichnung korrigieren') }}</span>
|
||||
@if ($adminTitleImage->isWithheld())
|
||||
<span class="badge">{{ __('vorläufig zurückgehalten') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 space-y-3 max-w-[760px]">
|
||||
<p class="m-0 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Kundenangabe und Bestätigung bleiben unverändert; jede Korrektur wird mit Grund und Bearbeiter protokolliert. Kundenangabe: :user · Portalkennzeichnung: :portal (:source).', [
|
||||
'user' => $adminTitleImage->ai_declaration_user?->label() ?? '–',
|
||||
'portal' => $adminTitleImage->ai_declaration?->label() ?? '–',
|
||||
'source' => $adminTitleImage->ai_declaration_source?->publicLabel() ?? '–',
|
||||
]) }}
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:input wire:model="correctionCredit" :label="__('Neuer Bildnachweis')" :placeholder="$adminTitleImage->copyright" />
|
||||
<flux:select wire:model="correctionAiDeclaration" :label="__('Neue Portalkennzeichnung')">
|
||||
<flux:select.option value="">{{ __('unverändert') }}</flux:select.option>
|
||||
@foreach (AiImageDeclaration::cases() as $aiOption)
|
||||
<flux:select.option :value="$aiOption->value">{{ $aiOption->label() }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<flux:textarea wire:model="correctionReason" :label="__('Grund (Pflicht)')" rows="2" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<flux:button variant="primary" wire:click="correctImage">{{ __('Korrektur übernehmen') }}</flux:button>
|
||||
<flux:button variant="filled" wire:click="toggleWithheld">
|
||||
{{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" size="sm" href="{{ route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $adminTitleImage->id, 'purpose' => 'Sichtprüfung im Admin']) }}" target="_blank">
|
||||
{{ __('Beweisoriginal öffnen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- ============== REJECTION-HINWEIS ============== --}}
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
|
||||
<article class="panel" style="border-color:var(--color-err); border-left-width:3px;">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\LeaveImpersonationController;
|
||||
use App\Http\Controllers\Admin\PressReleaseImageEvidenceController;
|
||||
use App\Http\Controllers\InvoicePdfController;
|
||||
use App\Http\Controllers\LegacyInvoicePdfController;
|
||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||
|
|
@ -42,6 +43,12 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
Volt::route('admin/press-releases/create', 'admin.press-releases.create')->name('admin.press-releases.create');
|
||||
Volt::route('admin/press-releases/{id}', 'admin.press-releases.show')->name('admin.press-releases.show');
|
||||
Volt::route('admin/press-releases/{id}/edit', 'admin.press-releases.edit')->name('admin.press-releases.edit');
|
||||
// Beweisoriginal und veröffentlichte Fassung (Bildkennzeichnung E.4):
|
||||
// nur hier, nur mit Zweck, jeder Abruf protokolliert.
|
||||
Route::get('admin/press-releases/{id}/images/{image}/original', [PressReleaseImageEvidenceController::class, 'original'])
|
||||
->whereNumber(['id', 'image'])->name('admin.press-releases.images.original');
|
||||
Route::get('admin/press-releases/{id}/images/{image}/published', [PressReleaseImageEvidenceController::class, 'published'])
|
||||
->whereNumber(['id', 'image'])->name('admin.press-releases.images.published');
|
||||
|
||||
// Rubriken-Taxonomie: portalweite Struktur — nur admin (Befund H2).
|
||||
Route::middleware('can:categories:manage')->group(function (): void {
|
||||
|
|
|
|||
376
tests/Feature/Images/ImageLifecycleTest.php
Normal file
376
tests/Feature/Images/ImageLifecycleTest.php
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung Block E: Veröffentlichungsgate, published_ever_at,
|
||||
* Snapshot, Sofortlöschung, Backfill, Legacy-Bereinigung, Beweiszugriff,
|
||||
* Korrekturweg, Systemstatus.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Storage::fake('local');
|
||||
Storage::fake('public');
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function lifecycleAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{author: User, company: Company, pr: PressRelease}
|
||||
*/
|
||||
function lifecycleRelease(string $status = 'review'): array
|
||||
{
|
||||
$author = User::factory()->create(['is_active' => true]);
|
||||
$author->assignRole('customer');
|
||||
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
|
||||
|
||||
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
|
||||
'user_id' => $author->id,
|
||||
'status' => $status,
|
||||
'slug' => 'lifecycle-'.fake()->unique()->numberBetween(1, 99999),
|
||||
]);
|
||||
|
||||
return compact('author', 'company', 'pr');
|
||||
}
|
||||
|
||||
function lifecycleImage(PressRelease $pr, bool $confirmed = true, array $attributes = []): PressReleaseImage
|
||||
{
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create($attributes);
|
||||
Storage::disk('local')->put($image->path, 'cover-bytes-'.$image->id);
|
||||
Storage::disk('local')->put($image->original_path, 'original-bytes-'.$image->id);
|
||||
|
||||
if ($confirmed) {
|
||||
ImageRightsConfirmation::query()->create([
|
||||
'subject_type' => $image->getMorphClass(),
|
||||
'subject_id' => $image->getKey(),
|
||||
'press_release_id' => $pr->id,
|
||||
'company_id' => $pr->company_id,
|
||||
'user_id' => $pr->user_id,
|
||||
'channel' => ConfirmationChannel::Form,
|
||||
'legal_text_key' => 'img-confirm-v2',
|
||||
'text_snapshot' => 'Ich bestätige …',
|
||||
'agb_version' => '0.6',
|
||||
'file_sha256' => $image->original_sha256,
|
||||
'declaration_snapshot' => [],
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $image->fresh();
|
||||
}
|
||||
|
||||
test('publishing marks release and images as ever published and captures the snapshot', function () {
|
||||
['pr' => $pr] = lifecycleRelease();
|
||||
$image = lifecycleImage($pr);
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
$pr->refresh();
|
||||
$image->refresh();
|
||||
|
||||
expect($pr->status)->toBe(PressReleaseStatus::Published)
|
||||
->and($pr->published_ever_at)->not->toBeNull()
|
||||
->and($image->published_ever_at)->not->toBeNull()
|
||||
->and($image->published_snapshot_path)->toStartWith('press-release-published/'.$pr->id.'/')
|
||||
->and(Storage::disk('local')->get($image->published_snapshot_path))->toBe('cover-bytes-'.$image->id)
|
||||
->and($image->published_snapshot_sha256)->toBe(hash('sha256', 'cover-bytes-'.$image->id));
|
||||
|
||||
$sidecar = json_decode((string) Storage::disk('local')->get(preg_replace('/\.jpg$/', '.json', $image->published_snapshot_path)), true);
|
||||
|
||||
expect($sidecar['credit_line'])->toContain('Foto: ')
|
||||
->and($sidecar['confirmation_key'])->toBe('img-confirm-v2')
|
||||
->and($sidecar['original_sha256'])->toBe($image->original_sha256);
|
||||
});
|
||||
|
||||
test('publishing is blocked while a formular v2 image lacks a valid confirmation', function () {
|
||||
['pr' => $pr] = lifecycleRelease();
|
||||
lifecycleImage($pr, confirmed: false, attributes: ['customer_confirmation_requested_at' => now()]);
|
||||
|
||||
expect(fn () => app(PressReleaseService::class)->publish($pr))
|
||||
->toThrow(ImageRightsConfirmationRequiredException::class);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review)
|
||||
->and($pr->fresh()->published_ever_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a legacy image never blocks publishing', function () {
|
||||
['pr' => $pr] = lifecycleRelease();
|
||||
PressReleaseImage::factory()->for($pr)->legacy()->create();
|
||||
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published);
|
||||
});
|
||||
|
||||
test('the admin status change to published applies the same gate and marks images', function () {
|
||||
['pr' => $pr] = lifecycleRelease('draft');
|
||||
$image = lifecycleImage($pr, confirmed: false);
|
||||
|
||||
expect(fn () => app(PressReleaseService::class)->changeStatusFromAdmin($pr, PressReleaseStatus::Published))
|
||||
->toThrow(ImageRightsConfirmationRequiredException::class);
|
||||
|
||||
['pr' => $pr2] = lifecycleRelease('draft');
|
||||
$image2 = lifecycleImage($pr2);
|
||||
|
||||
app(PressReleaseService::class)->changeStatusFromAdmin($pr2, PressReleaseStatus::Published);
|
||||
|
||||
expect($pr2->fresh()->published_ever_at)->not->toBeNull()
|
||||
->and($image2->fresh()->published_ever_at)->not->toBeNull()
|
||||
->and($image2->fresh()->published_snapshot_path)->not->toBeNull()
|
||||
->and($image->fresh()->published_ever_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('the admin page reports the gate instead of crashing', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = lifecycleRelease();
|
||||
lifecycleImage($pr, confirmed: false);
|
||||
$this->actingAs(lifecycleAdmin());
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->call('publish')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review);
|
||||
});
|
||||
|
||||
test('deleting a never published release from the admin removes images, originals and confirmations for good', function () {
|
||||
['pr' => $pr] = lifecycleRelease('draft');
|
||||
$image = lifecycleImage($pr);
|
||||
|
||||
app(PressReleaseService::class)->deleteFromAdmin($pr);
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
|
||||
->and(ImageRightsConfirmation::query()->count())->toBe(0)
|
||||
->and(Storage::disk('local')->allFiles())->toBe([])
|
||||
->and(PressRelease::withTrashed()->find($pr->id)?->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the draft purge deletes images of stale never published drafts immediately', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = lifecycleRelease('draft');
|
||||
$image = lifecycleImage($pr);
|
||||
DB::table('press_releases')->where('id', $pr->id)->update(['updated_at' => now()->subDays(200)]);
|
||||
|
||||
['pr' => $fresh] = lifecycleRelease('draft');
|
||||
$freshImage = lifecycleImage($fresh);
|
||||
|
||||
$this->artisan('press-releases:purge-drafts', ['--days' => 180])
|
||||
->expectsOutputToContain('Bilder gelöscht: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
|
||||
->and($pr->fresh()->status)->toBe(PressReleaseStatus::Archived)
|
||||
->and(PressReleaseImage::query()->whereKey($freshImage->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the published-ever backfill derives the date from the status log and lets images inherit it', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = lifecycleRelease('archived');
|
||||
$pr->forceFill(['published_at' => null, 'published_ever_at' => null])->save();
|
||||
PressReleaseStatusLog::query()->create([
|
||||
'press_release_id' => $pr->id,
|
||||
'from_status' => 'review',
|
||||
'to_status' => 'published',
|
||||
'source' => 'admin',
|
||||
'created_at' => now()->subDays(30),
|
||||
]);
|
||||
$image = PressReleaseImage::factory()->for($pr)->legacy()->create();
|
||||
['pr' => $neverPublished] = lifecycleRelease('rejected');
|
||||
$neverPublished->forceFill(['published_at' => null])->save();
|
||||
|
||||
$this->artisan('press-releases:backfill-published-ever', ['--dry-run' => true])
|
||||
->expectsOutputToContain('die öffentlich waren: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->artisan('press-releases:backfill-published-ever')->assertSuccessful();
|
||||
|
||||
expect($pr->fresh()->published_ever_at?->toDateString())->toBe(now()->subDays(30)->toDateString())
|
||||
->and($image->fresh()->published_ever_at)->not->toBeNull()
|
||||
->and($neverPublished->fresh()->published_ever_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('the legacy purge removes only legacy images of inactive releases and reports per status', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $rejected] = lifecycleRelease('rejected');
|
||||
['pr' => $published] = lifecycleRelease('published');
|
||||
['pr' => $draftV2] = lifecycleRelease('draft');
|
||||
|
||||
$legacyGone = PressReleaseImage::factory()->for($rejected)->legacy()->create(['disk' => 'local', 'path' => 'presseecho/press/alt.jpg']);
|
||||
Storage::disk('local')->put('presseecho/press/alt.jpg', 'alt');
|
||||
$legacyStays = PressReleaseImage::factory()->for($published)->legacy()->create();
|
||||
$v2Stays = lifecycleImage($draftV2);
|
||||
|
||||
$this->artisan('press-release-images:purge-inactive-legacy')
|
||||
->expectsOutputToContain('Trockenlauf: Legacy-Bilder an inaktiven Meldungen: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(PressReleaseImage::query()->count())->toBe(3);
|
||||
|
||||
$this->artisan('press-release-images:purge-inactive-legacy', ['--execute' => true])
|
||||
->expectsOutputToContain('Gelöscht: 1 Bilder')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(PressReleaseImage::withTrashed()->whereKey($legacyGone->id)->exists())->toBeFalse()
|
||||
->and(Storage::disk('local')->exists('presseecho/press/alt.jpg'))->toBeFalse()
|
||||
->and(PressReleaseImage::query()->whereKey($legacyStays->id)->exists())->toBeTrue()
|
||||
->and(PressReleaseImage::query()->whereKey($v2Stays->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the evidence original is only reachable by admins with a purpose and every access is logged', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr, 'author' => $author] = lifecycleRelease('published');
|
||||
$image = lifecycleImage($pr);
|
||||
$admin = lifecycleAdmin();
|
||||
|
||||
$url = route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $image->id], absolute: false);
|
||||
|
||||
$this->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen')->assertRedirect();
|
||||
|
||||
$this->actingAs($author)->get('https://pressekonto.test'.$url.'?purpose=x')->assertStatus(403);
|
||||
|
||||
$this->actingAs($admin)->get('https://pressekonto.test'.$url)->assertStatus(422);
|
||||
|
||||
$response = $this->actingAs($admin)->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen');
|
||||
$response->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private');
|
||||
expect($response->streamedContent())->toBe('original-bytes-'.$image->id);
|
||||
|
||||
$event = ActivityEvent::query()->where('type', ActivityType::EvidenceAccess->value)->sole();
|
||||
expect($event->description)->toContain('Abmahnung prüfen')
|
||||
->and($event->subject_id)->toBe($image->id);
|
||||
|
||||
// Die Medienroute kennt das Original nicht: „original" ist dort das Cover.
|
||||
$media = $this->get(route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'original']));
|
||||
$media->assertSuccessful();
|
||||
expect($media->streamedContent())->toBe('cover-bytes-'.$image->id);
|
||||
});
|
||||
|
||||
test('the evidence original path never leaks outside the allowed files', function () {
|
||||
$allowed = [
|
||||
'app/Models/PressReleaseImage.php',
|
||||
'app/Services/PressRelease/Images/PressReleaseImageUploader.php',
|
||||
'app/Services/PressRelease/Images/PressReleaseImageRemover.php',
|
||||
'app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php',
|
||||
];
|
||||
|
||||
$offenders = collect(array_merge(
|
||||
glob(base_path('app/**/*.php')) ?: [],
|
||||
glob(base_path('app/**/**/*.php')) ?: [],
|
||||
glob(base_path('app/**/**/**/*.php')) ?: [],
|
||||
glob(base_path('resources/views/**/*.blade.php')) ?: [],
|
||||
glob(base_path('resources/views/**/**/*.blade.php')) ?: [],
|
||||
glob(base_path('resources/views/**/**/**/*.blade.php')) ?: [],
|
||||
))
|
||||
->unique()
|
||||
->filter(fn (string $file): bool => str_contains((string) file_get_contents($file), 'original_path'))
|
||||
->map(fn (string $file): string => str_replace(base_path().'/', '', $file))
|
||||
->reject(fn (string $file): bool => in_array($file, $allowed, true))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($offenders)->toBe([]);
|
||||
});
|
||||
|
||||
test('corrections are recorded with reason and source, and withholding hides the image publicly', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = lifecycleRelease();
|
||||
$image = lifecycleImage($pr, attributes: ['copyright' => 'Falscher Nachweis', 'ai_declaration' => 'original', 'ai_declaration_user' => 'original']);
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
$admin = lifecycleAdmin();
|
||||
|
||||
$service = app(ImageCorrectionService::class);
|
||||
$service->correctCredit($image, $admin, 'Richtiger Nachweis', 'Einwand des Fotografen per Mail vom 22.09.');
|
||||
$service->correctAiDeclaration($image, $admin, AiImageDeclaration::AiGenerated, 'Herkunftsdaten nachgereicht');
|
||||
|
||||
$image->refresh();
|
||||
|
||||
expect($image->copyright)->toBe('Richtiger Nachweis')
|
||||
->and($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated)
|
||||
->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original)
|
||||
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::AdminCorrection)
|
||||
->and(ImageDeclarationCorrection::query()->count())->toBe(2)
|
||||
->and(ImageDeclarationCorrection::query()->where('field', 'ai_declaration')->sole()->old_source)->toBe('customer')
|
||||
->and(ImageRightsConfirmation::query()->count())->toBe(1);
|
||||
|
||||
$this->get('https://presseecho.test/de/press-release/'.$pr->slug)
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Foto: Richtiger Nachweis · KI-generiert')
|
||||
->assertSeeText('Kennzeichnung: vom Portal nach Prüfung korrigiert');
|
||||
|
||||
$service->withhold($image, $admin, 'Einwand wird geprüft');
|
||||
|
||||
$this->get('https://presseecho.test/de/press-release/'.$pr->slug)
|
||||
->assertSuccessful()
|
||||
->assertDontSee('data-image-credit', false)
|
||||
->assertDontSeeText('Richtiger Nachweis');
|
||||
|
||||
$service->release($image->fresh(), $admin, 'Einwand unbegründet');
|
||||
|
||||
$this->get('https://presseecho.test/de/press-release/'.$pr->slug)->assertSeeText('Foto: Richtiger Nachweis');
|
||||
|
||||
expect(fn () => ImageDeclarationCorrection::query()->first()->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('the admin page applies a correction with a mandatory reason', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = lifecycleRelease('published');
|
||||
$image = lifecycleImage($pr);
|
||||
$this->actingAs(lifecycleAdmin());
|
||||
|
||||
LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Bildnachweis und Kennzeichnung korrigieren')
|
||||
->set('correctionAiDeclaration', 'ai_edited')
|
||||
->call('correctImage')
|
||||
->assertHasErrors(['correctionReason'])
|
||||
->set('correctionReason', 'Kunde hat KI-Retusche bestätigt')
|
||||
->call('correctImage')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($image->fresh()->ai_declaration)->toBe(AiImageDeclaration::AiEdited)
|
||||
->and(ImageDeclarationCorrection::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the system health warns about failed provenance checks of the last seven days', function () {
|
||||
['pr' => $pr] = lifecycleRelease('draft');
|
||||
PressReleaseImage::factory()->for($pr)->rulesV2()->create(['provenance_status' => 'error', 'provenance_checked_at' => now()->subDay()]);
|
||||
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
|
||||
expect($checks->get('image_provenance')['status'])->toBe('warn')
|
||||
->and($checks->get('image_provenance')['detail'])->toContain('1 Bild-Upload');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue