presseportale/app/Services/PressRelease/Images/PressReleaseImageRemover.php
Kevin Adametz 5ec252da12 images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit
  isPubliclyDeliverable()
- B-03: Livewire-Temp-Upload 16 MiB
- N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put()
  geprüft, alle Fassungen ins Archiv
- O03: published_ever_at in der Freigabetransaktion
  (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur
- B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre
- B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag;
  Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert
- B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme)
- B-08, B-09, B-11, B-12: published_ever_at nur bei Status published,
  Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale,
  Verarbeitungsfehler als Formularmeldung
- K-02-Teil: nach Kundenbestätigung erneut routen
- OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame

Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08
(Nachweis per POST), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

243 lines
10 KiB
PHP

<?php
namespace App\Services\PressRelease\Images;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\PressReleaseStatus;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\FileDeletion;
use App\Support\WebCaches;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
*
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
* und vollständig weg, die Zeile wird hart gelöscht — schlägt eine
* Dateilöschung fehl, bleibt die Zeile für den nächsten Versuch (5.1 e);
* - veröffentlicht: Zeile weich gelöscht und Derivate weg, Auftrag im
* Archiv-Nachholer; Original, alle Snapshot-Fassungen und Bestätigung ins
* Beweisarchiv (Block F), erst danach die lokalen Beweisdateien weg und
* die Pfade an der Zeile geleert.
*
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
* wie bisher behandelt (Derivate weg, Soft Delete).
*/
class PressReleaseImageRemover
{
public function __construct(
private readonly ImageService $imageService,
private readonly PressReleaseArchiver $archiver,
private readonly ArchivePendingQueue $pendingArchive,
) {}
/**
* `$allowedStatuses` ist die Status-Regel des Aufrufers (Kunde: Draft/
* Rejected, Admin: alles außer Archived). Meldung und Bild werden unter
* Sperre neu geladen, Status-Regel und `published_ever_at` erneut
* geprüft (Umsetzungsaudit 29.09., B-05, analog N07): Ein gleichzeitiges
* Veröffentlichen kann so kein öffentlich gewesenes Bild in die
* Sofortlöschung schicken. Null = keine Regel (System- und Admin-Wege,
* die den Status selbst verantworten).
*
* @param list<PressReleaseStatus>|null $allowedStatuses
*
* @throws ImageUploadException Status der Meldung passt nicht mehr.
*/
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?array $allowedStatuses = null): void
{
/** @var array{disk: string, path: string, variants: array<string, string>|null, public: bool, job: ArchivePendingJob|null}|null $derivatives */
$derivatives = DB::transaction(function () use ($image, $actor, $event, $reason, $legalRequest, $allowedStatuses): ?array {
$pressRelease = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($image->press_release_id)
->lockForUpdate()
->first();
$locked = PressReleaseImage::query()->whereKey($image->getKey())->lockForUpdate()->first();
if ($locked === null) {
return null;
}
if ($allowedStatuses !== null && ($pressRelease === null || ! in_array($pressRelease->status, $allowedStatuses, true))) {
throw new ImageUploadException(__('Der Status der Meldung hat sich geändert. Bitte laden Sie die Seite neu.'));
}
// Die Instanz des Aufrufers auf den gesperrten Stand bringen:
// entschieden wird nach dem, was jetzt in der Datenbank steht.
$image->setRawAttributes($locked->getAttributes(), true);
$derivatives = ['disk' => $image->disk, 'path' => $image->path, 'variants' => $image->variants, 'public' => false, 'job' => null];
if ($image->wasEverPublished() || $image->isLegacyRules()) {
// Veröffentlicht: Auftrag „Archivierung steht aus" in derselben
// Transaktion wie das Ausblenden (E15, Umsetzungsaudit 29.09.).
// Der Eintrag folgt nach dem Commit; scheitert er oder fehlt
// der Schlüssel, bleibt der Auftrag offen und die lokalen
// Beweisdateien bleiben liegen, bis archive:retry-pending ihn
// schreibt.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
$derivatives['job'] = $this->pendingArchive->push(
(int) $image->press_release_id,
$event,
$image,
$reason,
$legalRequest?->getKey(),
$actor !== null ? (int) $actor->getKey() : null,
);
}
$image->delete();
return ['public' => true] + $derivatives;
}
foreach ($image->rightsConfirmations()->get() as $confirmation) {
$confirmation->purgeForUnpublished();
}
$disk = $this->evidenceDisk($image);
foreach (array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)]))) as $path) {
FileDeletion::deleteOrThrow($disk, $path, 'Sofortlöschung Bild #'.$image->getKey());
}
$image->forceDelete();
return $derivatives;
});
if ($derivatives === null) {
return;
}
if ($derivatives['job'] !== null) {
$this->pendingArchive->completeWith($derivatives['job'], fn () => $this->archiveTrashed($image, $actor, $event, $reason, $legalRequest));
}
$this->imageService->deletePressReleaseImage($derivatives['disk'], $derivatives['path'], $derivatives['variants']);
if ($derivatives['public']) {
try {
WebCaches::forgetPublicPages();
} catch (Throwable $exception) {
report($exception);
}
}
}
/**
* Archiveintrag für ein veröffentlichtes v2-Bild anlegen und danach die
* lokalen Beweisdateien entfernen. Auch für den Nachholer
* (archive:retry-pending), wenn ein Bild bereits weich gelöscht ist,
* aber kein Eintrag existiert.
*/
public function archiveTrashed(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry
{
$entry = $this->archiver->imageRemoved($image, $actor, $event, $reason, $legalRequest, $note);
if ($entry !== null) {
$this->deleteEvidenceFiles($image);
}
return $entry;
}
/**
* Lokale Beweisdateien nach erfolgreicher Überführung entfernen. Ein
* Fehlschlag betrifft nur die Datenminimierung — er wird protokolliert,
* die Pfade bleiben stehen, der Systemstatus zählt sie (5.1 e).
*/
private function deleteEvidenceFiles(PressReleaseImage $image): void
{
$disk = $this->evidenceDisk($image);
// Alle Snapshot-Fassungen (N09) liegen jetzt im Archiv.
$paths = array_values(array_unique(array_filter([$image->original_path, ...PublishedImageSnapshot::versionPaths($image)])));
$remaining = FileDeletion::deleteOrLog($disk, $paths, 'Beweisdateien Bild #'.$image->getKey());
// Pfade an der Zeile leeren (nur die gelöschten) — über die Query,
// damit die übergebene Instanz für den Aufrufer unverändert bleibt.
PressReleaseImage::withTrashed()->whereKey($image->getKey())->update([
'original_path' => in_array($image->original_path, $remaining, true) ? $image->original_path : null,
'published_snapshot_path' => in_array($image->published_snapshot_path, $remaining, true) ? $image->published_snapshot_path : null,
]);
}
/**
* Entfernte v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt
* sind (Überführung ausstehend oder Löschung fehlgeschlagen) — für den
* Admin-Systemstatus (5.1 e). Die Pfade bleiben hier gekapselt (E.4).
*/
public static function countLocalEvidenceLeftovers(): int
{
return PressReleaseImage::query()->onlyTrashed()
->whereNotNull('rules_version')
->whereNotNull('published_ever_at')
->where(fn ($q) => $q->whereNotNull('original_path')->orWhereNotNull('published_snapshot_path'))
->count();
}
/**
* Beweisoriginale ohne Bildzeile (Umsetzungsaudit 29.09., B-11): Der
* Uploader schreibt das Original vor der Verarbeitung; ein harter
* Abbruch (Fatal Error) hinterlässt eine Datei ohne Zeile. Dateien,
* auf die eine Zeile zeigt — auch weich gelöschte —, bleiben liegen.
* Die Pfade bleiben hier gekapselt (E.4).
*
* @param (callable(string): void)|null $report erhält jeden verwaisten Pfad
* @return array{scanned: int, orphans: int, removed: int}
*/
public function sweepOrphanOriginals(bool $execute, int $olderThanHours, ?callable $report = null): array
{
$disk = Storage::disk((string) config('images.originals_disk', 'local'));
$threshold = now()->subHours(max(1, $olderThanHours))->getTimestamp();
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
foreach (array_chunk($disk->allFiles((string) config('images.originals_directory')), 500) as $chunk) {
$result['scanned'] += count($chunk);
$known = PressReleaseImage::withTrashed()
->whereIn('original_path', $chunk)
->pluck('original_path')
->all();
foreach (array_diff($chunk, $known) as $file) {
if ($disk->lastModified($file) > $threshold) {
continue;
}
$result['orphans']++;
if ($report !== null) {
$report($file);
}
if ($execute && FileDeletion::delete($disk, $file)) {
$result['removed']++;
}
}
}
return $result;
}
private function evidenceDisk(PressReleaseImage $image): Filesystem
{
return Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
}
}