- 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>
243 lines
10 KiB
PHP
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'));
|
|
}
|
|
}
|