- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue), Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel FAILURE; archive_pending_at nur noch Sammelmarker - O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre - O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder offener Auftrag, nie ein still unvollständiger Nachweis - A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore, archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut - O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab - A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold- Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export - 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock), N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für auslieferbare Bilder), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
15 KiB
PHP
336 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Archive;
|
|
|
|
use App\Enums\ArchiveEvent;
|
|
use App\Enums\ArchiveReasonCategory;
|
|
use App\Models\ArchiveEntry;
|
|
use App\Models\ImageDeclarationCorrection;
|
|
use App\Models\LegalRequest;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseImage;
|
|
use App\Models\User;
|
|
use App\Scopes\PortalScope;
|
|
use App\Services\PressRelease\Images\PublishedImageSnapshot;
|
|
use App\Support\ImageCredit;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Baut die Archiv-Payloads für die Ereignisse aus Decision-Update 4.1 und
|
|
* ruft das Beweisarchiv. Fachdaten liegen im Manifest, Dateien (Original,
|
|
* veröffentlichte Fassung) im ZIP. Fehlt der Schlüssel, protokolliert das
|
|
* Archiv den Ausfall; die Geschäftsvorgänge laufen trotzdem weiter. Der
|
|
* Bezug zum Vorgang im Meldeverfahren liegt als Spalte am Eintrag (5.1 d).
|
|
*/
|
|
class PressReleaseArchiver
|
|
{
|
|
public function __construct(private readonly EvidenceArchive $archive) {}
|
|
|
|
/**
|
|
* Veröffentlichtes Bild entfernt oder ersetzt: Original, veröffentlichte
|
|
* Fassung, Bestätigungen und Korrekturen wandern ins Archiv.
|
|
*/
|
|
public function imageRemoved(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest, ?LegalRequest $legalRequest = null, ?string $note = null): ?ArchiveEntry
|
|
{
|
|
$files = [];
|
|
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
|
|
|
|
if ($image->original_path !== null && $disk->exists($image->original_path)) {
|
|
$files['original.'.pathinfo($image->original_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->original_path);
|
|
}
|
|
|
|
if ($image->published_snapshot_path !== null && $disk->exists($image->published_snapshot_path)) {
|
|
$files['published.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->published_snapshot_path);
|
|
$sidecar = PublishedImageSnapshot::sidecarPath($image->published_snapshot_path);
|
|
|
|
if (is_string($sidecar) && $disk->exists($sidecar)) {
|
|
$files['published.json'] = (string) $disk->get($sidecar);
|
|
}
|
|
}
|
|
|
|
// Jede weitere Fassung des Veröffentlichungssnapshots (N09): OG mit
|
|
// eingebranntem Nachweis sowie Cover und Sidecar früherer
|
|
// Korrekturstände. Die aktuelle Fassung liegt schon als published.*.
|
|
$current = array_filter([$image->published_snapshot_path, PublishedImageSnapshot::sidecarPath($image->published_snapshot_path)]);
|
|
|
|
foreach (array_diff(PublishedImageSnapshot::versionPaths($image), $current) as $versionPath) {
|
|
$files['published-history/'.basename($versionPath)] = (string) $disk->get($versionPath);
|
|
}
|
|
|
|
$pressRelease = $image->pressRelease;
|
|
|
|
return $this->archive->record(
|
|
$event,
|
|
$image,
|
|
$image->press_release_id,
|
|
$reason,
|
|
$this->actorType($actor),
|
|
$actor?->getKey(),
|
|
[
|
|
'image' => $image->only([
|
|
'id', 'press_release_id', 'path', 'variants', 'title', 'description', 'copyright', 'credit_source', 'author',
|
|
'license_url', 'source_url', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked',
|
|
'provenance_status', 'provenance', 'original_sha256', 'published_snapshot_sha256', 'rules_version',
|
|
'published_ever_at', 'withheld_at', 'precheck_withheld_at', 'precheck', 'precheck_outcome', 'precheck_review_required_at',
|
|
'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at', 'deleted_at',
|
|
]),
|
|
'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null,
|
|
'confirmations' => $image->rightsConfirmations()->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
|
|
'corrections' => ImageDeclarationCorrection::query()->where('press_release_image_id', $image->getKey())->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
|
|
'press_release' => $pressRelease ? $this->pressReleaseHeader($pressRelease) : null,
|
|
'legal_request' => $this->legalRequestBlock($legalRequest),
|
|
'note' => $note,
|
|
],
|
|
$files,
|
|
['image_id' => $image->getKey(), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()],
|
|
$legalRequest?->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Meldung aus dem öffentlichen Angebot genommen: letzter veröffentlichter
|
|
* Stand mit Text, Bildern (Snapshots), Kontaktfeldern, Einreicher, Zeiten.
|
|
* Blockierender Direktweg (deleteFromAdmin); alle anderen Wege halten den
|
|
* Stand zuerst im Auftrag fest (depublicationState) und archivieren über
|
|
* recordDepublished().
|
|
*/
|
|
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry
|
|
{
|
|
return $this->recordDepublished($pressRelease, $this->depublicationState($pressRelease), $reason, $actor, $note, $legalRequest);
|
|
}
|
|
|
|
/**
|
|
* Stand der Meldung zum Zeitpunkt der Depublikation (Umsetzungsaudit
|
|
* 29.09.2026, A-04): wird in der fachlichen Transaktion am Auftrag
|
|
* gespeichert, damit ein nachgeholter Eintrag nicht den Stand nach einer
|
|
* späteren Bearbeitung oder Schwärzung abbildet. Firma und Kontakte ohne
|
|
* PortalScope — ein Request mit anderem Portal-Kontext darf sie nicht
|
|
* aus dem Nachweis filtern. Nur JSON-taugliche Werte.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function depublicationState(PressRelease $pressRelease): array
|
|
{
|
|
$pressRelease->load([
|
|
'company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class),
|
|
'contacts' => fn ($q) => $q->withoutGlobalScope(PortalScope::class),
|
|
'user',
|
|
'images',
|
|
]);
|
|
|
|
$images = [];
|
|
|
|
foreach ($pressRelease->images as $image) {
|
|
$images[] = [
|
|
'id' => $image->getKey(),
|
|
'credit_line' => ImageCredit::for($image, $pressRelease->company)->line(),
|
|
'ai_declaration' => $image->ai_declaration?->value,
|
|
'ai_declaration_source' => $image->ai_declaration_source?->value,
|
|
'original_sha256' => $image->original_sha256,
|
|
'published_snapshot_sha256' => $image->published_snapshot_sha256,
|
|
'rules_version' => $image->rules_version,
|
|
'snapshot_disk' => $image->original_disk ?? (string) config('images.originals_disk'),
|
|
'snapshot_path' => $image->published_snapshot_path,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'captured_at' => now()->toIso8601String(),
|
|
'press_release' => $this->pressReleaseHeader($pressRelease) + $pressRelease->only([
|
|
'subtitle', 'text', 'keywords', 'backlink_url', 'language', 'category_id', 'boilerplate_override',
|
|
]),
|
|
'contacts' => $pressRelease->contacts->map(fn ($contact) => $contact->only(['id', 'title', 'first_name', 'last_name', 'email', 'phone', 'responsibility']))->values()->all(),
|
|
'images' => $images,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Archiveintrag `pm_depublished` aus einem festgehaltenen Stand. Die
|
|
* Snapshot-Dateien werden beim Schreiben gelesen, sofern noch vorhanden;
|
|
* Speicherpfade bleiben außerhalb des Manifests (E.4).
|
|
*
|
|
* @param array<string, mixed> $state aus depublicationState()
|
|
*/
|
|
public function recordDepublished(PressRelease $pressRelease, array $state, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry
|
|
{
|
|
$files = [];
|
|
$images = [];
|
|
|
|
foreach ((array) ($state['images'] ?? []) as $image) {
|
|
$path = $image['snapshot_path'] ?? null;
|
|
$diskName = $image['snapshot_disk'] ?? null;
|
|
unset($image['snapshot_path'], $image['snapshot_disk']);
|
|
|
|
if (is_string($path) && $path !== '' && is_string($diskName)) {
|
|
$disk = Storage::disk($diskName);
|
|
|
|
if ($disk->exists($path)) {
|
|
$name = 'image-'.$image['id'].'.'.pathinfo($path, PATHINFO_EXTENSION);
|
|
$files[$name] = (string) $disk->get($path);
|
|
$image['file'] = $name;
|
|
} else {
|
|
// Datei weg (Umsetzungsaudit 02.10.2026, O08): Wurde das
|
|
// Bild inzwischen selbst archiviert, verweist der Nachweis
|
|
// verbindlich darauf — dort liegen alle Snapshot-Fassungen.
|
|
// Sonst bleibt der Auftrag mit sichtbarem Fehler offen,
|
|
// statt still einen unvollständigen Nachweis abzulegen.
|
|
$image['archived_in'] = $this->imageArchiveReference((int) $image['id']);
|
|
}
|
|
} else {
|
|
// Altbestand ohne Veröffentlichungssnapshot: ausdrücklich
|
|
// kennzeichnen, nicht stillschweigend weglassen.
|
|
$image['snapshot'] = 'none';
|
|
}
|
|
|
|
$images[] = $image;
|
|
}
|
|
|
|
return $this->archive->record(
|
|
ArchiveEvent::PressReleaseDepublished,
|
|
$pressRelease,
|
|
$pressRelease->getKey(),
|
|
$reason,
|
|
$this->actorType($actor),
|
|
$actor?->getKey(),
|
|
[
|
|
'press_release' => (array) ($state['press_release'] ?? []),
|
|
'contacts' => (array) ($state['contacts'] ?? []),
|
|
'images' => $images,
|
|
'state_captured_at' => $state['captured_at'] ?? null,
|
|
'legal_request' => $this->legalRequestBlock($legalRequest),
|
|
'note' => $note,
|
|
],
|
|
$files,
|
|
['images' => count($images), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()],
|
|
$legalRequest?->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Verweis auf den Archiveintrag, in dem das Bild samt Snapshot-Fassungen
|
|
* liegt; ohne einen solchen fehlt der Nachweis — Ausnahme, der Auftrag
|
|
* bleibt offen.
|
|
*
|
|
* @return array{archive_entry_id: int, event: string}
|
|
*/
|
|
private function imageArchiveReference(int $imageId): array
|
|
{
|
|
$entry = ArchiveEntry::query()
|
|
->where('subject_type', (new PressReleaseImage)->getMorphClass())
|
|
->where('subject_id', $imageId)
|
|
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($entry === null) {
|
|
throw new RuntimeException(sprintf('Beweisarchiv: Snapshot von Bild #%d fehlt und ist nicht anderweitig archiviert — Depublikationsnachweis bleibt offen.', $imageId));
|
|
}
|
|
|
|
return ['archive_entry_id' => (int) $entry->getKey(), 'event' => $entry->event->value];
|
|
}
|
|
|
|
/**
|
|
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
|
|
* den Antrag. Der Vorher-Stand kommt aus dem Redaction-Snapshot, damit
|
|
* eine Wiederholung denselben Nachweis liefert (N11).
|
|
*
|
|
* @param array<string, mixed> $before
|
|
* @param array<string, mixed> $after
|
|
*/
|
|
public function gdprChange(PressRelease $pressRelease, array $before, array $after, ?LegalRequest $legalRequest, ?User $actor = null, ArchiveReasonCategory $reason = ArchiveReasonCategory::Gdpr, ?string $note = null, ?int $redactionSnapshotId = null): ?ArchiveEntry
|
|
{
|
|
return $this->archive->record(
|
|
ArchiveEvent::GdprChange,
|
|
$pressRelease,
|
|
$pressRelease->getKey(),
|
|
$reason,
|
|
$this->actorType($actor),
|
|
$actor?->getKey(),
|
|
[
|
|
'press_release' => $this->pressReleaseHeader($pressRelease),
|
|
'before' => $before,
|
|
'after' => $after,
|
|
'legal_request' => $this->legalRequestBlock($legalRequest),
|
|
'redaction_snapshot_id' => $redactionSnapshotId,
|
|
'note' => $note,
|
|
],
|
|
[],
|
|
['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey(), 'redaction_snapshot_id' => $redactionSnapshotId],
|
|
$legalRequest?->getKey(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Korrekturweg (E.6): alter und neuer Wert, Quelle, Grund, Bearbeiter.
|
|
*/
|
|
public function labelCorrected(PressReleaseImage $image, ImageDeclarationCorrection $correction, ?User $actor = null): ?ArchiveEntry
|
|
{
|
|
return $this->archive->record(
|
|
ArchiveEvent::LabelCorrected,
|
|
$image,
|
|
$image->press_release_id,
|
|
ArchiveReasonCategory::Correction,
|
|
$this->actorType($actor),
|
|
$actor?->getKey(),
|
|
[
|
|
'correction' => $correction->toArray(),
|
|
'image' => $image->only(['id', 'copyright', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'withheld_at']),
|
|
],
|
|
[],
|
|
['correction_id' => $correction->getKey(), 'field' => $correction->field],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function legalRequestBlock(?LegalRequest $legalRequest): ?array
|
|
{
|
|
if ($legalRequest === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => $legalRequest->getKey(),
|
|
'reference' => $legalRequest->reference(),
|
|
'type' => $legalRequest->type->value,
|
|
'requester_email' => $legalRequest->requester_email,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function pressReleaseHeader(PressRelease $pressRelease): array
|
|
{
|
|
if (! $pressRelease->relationLoaded('company') || $pressRelease->company === null) {
|
|
$pressRelease->load(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]);
|
|
}
|
|
|
|
$pressRelease->loadMissing('user');
|
|
|
|
return [
|
|
'id' => $pressRelease->getKey(),
|
|
'uuid' => $pressRelease->uuid,
|
|
'portal' => $pressRelease->portal->value,
|
|
'title' => $pressRelease->title,
|
|
'slug' => $pressRelease->slug,
|
|
'status' => $pressRelease->status->value,
|
|
'published_at' => $pressRelease->published_at?->toIso8601String(),
|
|
'published_ever_at' => $pressRelease->published_ever_at?->toIso8601String(),
|
|
'depublished_at' => $pressRelease->depublished_at?->toIso8601String(),
|
|
'company' => $pressRelease->company ? ['id' => $pressRelease->company->getKey(), 'name' => $pressRelease->company->name] : null,
|
|
'submitter' => $pressRelease->user ? ['id' => $pressRelease->user->getKey(), 'email' => $pressRelease->user->email, 'name' => $pressRelease->user->name] : null,
|
|
];
|
|
}
|
|
|
|
private function actorType(?User $actor): string
|
|
{
|
|
if ($actor === null) {
|
|
return 'system';
|
|
}
|
|
|
|
return $actor->canAccessAdmin() ? 'admin' : 'customer';
|
|
}
|
|
}
|