diff --git a/app/Console/Commands/BackfillPublishedEver.php b/app/Console/Commands/BackfillPublishedEver.php new file mode 100644 index 00000000..aaaa0486 --- /dev/null +++ b/app/Console/Commands/BackfillPublishedEver.php @@ -0,0 +1,62 @@ +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; + } +} diff --git a/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php b/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php index d2c36023..2fdc9eac 100644 --- a/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php +++ b/app/Console/Commands/PurgeExpiredPressReleaseDrafts.php @@ -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; } diff --git a/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php b/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php new file mode 100644 index 00000000..b8ede2c8 --- /dev/null +++ b/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php @@ -0,0 +1,89 @@ +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; + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index afd19172..a317a72a 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -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', }; diff --git a/app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php b/app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php new file mode 100644 index 00000000..36a54e26 --- /dev/null +++ b/app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php @@ -0,0 +1,63 @@ +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', + ]); + } +} diff --git a/app/Models/ImageDeclarationCorrection.php b/app/Models/ImageDeclarationCorrection.php new file mode 100644 index 00000000..d34f31a8 --- /dev/null +++ b/app/Models/ImageDeclarationCorrection.php @@ -0,0 +1,62 @@ + '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 + */ + public function image(): BelongsTo + { + return $this->belongsTo(PressReleaseImage::class, 'press_release_image_id'); + } + + /** + * @return BelongsTo + */ + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } +} diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index 332f17b0..9393f96c 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -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', diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 26da76c7..8f7c4776 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -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 $query + * @return Builder + */ + public function scopePubliclyVisible(Builder $query): Builder + { + return $query->whereNull('withheld_at'); + } + public function url(bool $signed = false): ?string { if (blank($this->path)) { diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 8339dab6..9ed7e2a9 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -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(); diff --git a/app/Services/PressRelease/ImageRightsConfirmationRequiredException.php b/app/Services/PressRelease/ImageRightsConfirmationRequiredException.php new file mode 100644 index 00000000..7be6e366 --- /dev/null +++ b/app/Services/PressRelease/ImageRightsConfirmationRequiredException.php @@ -0,0 +1,18 @@ +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; + } +} diff --git a/app/Services/PressRelease/Images/PublishedImageSnapshot.php b/app/Services/PressRelease/Images/PublishedImageSnapshot.php new file mode 100644 index 00000000..697ef192 --- /dev/null +++ b/app/Services/PressRelease/Images/PublishedImageSnapshot.php @@ -0,0 +1,85 @@ +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(); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 6dd41a50..a98a786c 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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 diff --git a/app/Services/Web/CategoryPageData.php b/app/Services/Web/CategoryPageData.php index 7a0512f9..c999339c 100644 --- a/app/Services/Web/CategoryPageData.php +++ b/app/Services/Web/CategoryPageData.php @@ -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 diff --git a/app/Services/Web/HomePageData.php b/app/Services/Web/HomePageData.php index b17699cd..02877741 100644 --- a/app/Services/Web/HomePageData.php +++ b/app/Services/Web/HomePageData.php @@ -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), diff --git a/app/Services/Web/NewsroomDetailPageData.php b/app/Services/Web/NewsroomDetailPageData.php index 19c2564f..1fb39873 100644 --- a/app/Services/Web/NewsroomDetailPageData.php +++ b/app/Services/Web/NewsroomDetailPageData.php @@ -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(); diff --git a/app/Services/Web/ReleaseDetailPageData.php b/app/Services/Web/ReleaseDetailPageData.php index 87f2d187..aa11cb59 100644 --- a/app/Services/Web/ReleaseDetailPageData.php +++ b/app/Services/Web/ReleaseDetailPageData.php @@ -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 diff --git a/database/migrations/2026_09_22_110000_add_published_ever_at_to_press_releases_table.php b/database/migrations/2026_09_22_110000_add_published_ever_at_to_press_releases_table.php new file mode 100644 index 00000000..b97ba4e8 --- /dev/null +++ b/database/migrations/2026_09_22_110000_add_published_ever_at_to_press_releases_table.php @@ -0,0 +1,35 @@ +timestamp('published_ever_at')->nullable()->after('published_at'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn('published_ever_at'); + }); + } +}; diff --git a/database/migrations/2026_09_22_110100_add_withheld_at_to_press_release_images_table.php b/database/migrations/2026_09_22_110100_add_withheld_at_to_press_release_images_table.php new file mode 100644 index 00000000..21f88c42 --- /dev/null +++ b/database/migrations/2026_09_22_110100_add_withheld_at_to_press_release_images_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_09_22_110200_create_image_declaration_corrections_table.php b/database/migrations/2026_09_22_110200_create_image_declaration_corrections_table.php new file mode 100644 index 00000000..46f20a35 --- /dev/null +++ b/database/migrations/2026_09_22_110200_create_image_declaration_corrections_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 91eae99d..2e6f1cde 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -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\\. Use \-\> instead\.$#' identifier: nullsafe.neverNull diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index a7118319..df588f65 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -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 + {{-- ============== KORREKTURWEG BILD (E.6) ============== --}} + @if (! $coverIsPlaceholder && isset($adminTitleImage) && $adminTitleImage && ! $adminTitleImage->isLegacyRules()) +
+
+ {{ __('Bildnachweis und Kennzeichnung korrigieren') }} + @if ($adminTitleImage->isWithheld()) + {{ __('vorläufig zurückgehalten') }} + @endif +
+
+

+ {{ __('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() ?? '–', + ]) }} +

+
+ + + {{ __('unverändert') }} + @foreach (AiImageDeclaration::cases() as $aiOption) + {{ $aiOption->label() }} + @endforeach + +
+ +
+ {{ __('Korrektur übernehmen') }} + + {{ $adminTitleImage->isWithheld() ? __('Bild wieder freigeben') : __('Bild vorläufig zurückhalten') }} + + + {{ __('Beweisoriginal öffnen') }} + +
+
+
+ @endif + {{-- ============== REJECTION-HINWEIS ============== --}} @if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
diff --git a/routes/admin.php b/routes/admin.php index 9a3b2678..64224f30 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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 { diff --git a/tests/Feature/Images/ImageLifecycleTest.php b/tests/Feature/Images/ImageLifecycleTest.php new file mode 100644 index 00000000..45c5a8f4 --- /dev/null +++ b/tests/Feature/Images/ImageLifecycleTest.php @@ -0,0 +1,376 @@ +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'); +});