presseportale/app/Services/Archive/EvidenceArchive.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

585 lines
21 KiB
PHP

<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\ImageDeclarationCorrection;
use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Support\FileDeletion;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
use ZipArchive;
/**
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4).
*
* Jeder Eintrag ist ein verschlüsseltes ZIP (manifest.json plus Dateien)
* auf der Archiv-Disk, eigener Schlüssel (ARCHIVE_KEY), SHA-256 des
* Klartext-ZIPs am Eintrag. Lesen nur über open()/export() mit Zweck und
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
* anhand des täglich exportierten Tombstone-Stands und der Tombstones in
* der Datenbank.
*
* Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10):
* ein Tombstone entsteht erst nach bestätigter Löschung, Purge und
* Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt.
* Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02).
*/
class EvidenceArchive
{
public function isConfigured(): bool
{
return $this->keyBytes() !== null;
}
/**
* Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann
* die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile
* noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem
* Verschieben gescheitert) räumt sweepOrphans() auf.
*
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
* @param array<string, string> $files Dateiname → Bytes
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
*/
public function record(
ArchiveEvent $event,
Model $subject,
?int $pressReleaseId,
ArchiveReasonCategory $reason,
string $actorType,
?int $actorId,
array $manifest,
array $files = [],
array $summary = [],
?int $legalRequestId = null,
): ?ArchiveEntry {
if (! $this->isConfigured()) {
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
'event' => $event->value,
'subject' => $subject->getMorphClass().'#'.$subject->getKey(),
]);
return null;
}
$manifest = [
'event' => $event->value,
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'legal_request_id' => $legalRequestId,
'reason_category' => $reason->value,
'actor_type' => $actorType,
'actor_id' => $actorId,
'recorded_at' => now()->toIso8601String(),
] + $manifest;
$zip = $this->buildZip($manifest, $files);
$hash = hash('sha256', $zip);
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
$temp = $path.'.tmp';
if (! $this->disk()->put($temp, $this->encrypter()->encrypt($zip, false))) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
}
try {
return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry {
$entry = ArchiveEntry::query()->create([
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'legal_request_id' => $legalRequestId,
'event' => $event,
'actor_type' => $actorType,
'actor_id' => $actorId,
'reason_category' => $reason,
'summary' => $summary ?: null,
'payload_path' => $path,
'payload_hash' => $hash,
'payload_bytes' => strlen($zip),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
'created_at' => now(),
]);
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.');
}
return $entry;
});
} catch (Throwable $exception) {
$this->disk()->delete($temp);
throw $exception;
}
}
/**
* Entschlüsseltes ZIP eines Eintrags — protokolliert mit Zweck.
*/
public function open(ArchiveEntry $entry, User $admin, string $purpose): string
{
$this->logAccess($entry, $admin, 'open', $purpose);
return $this->plaintext($entry);
}
/**
* manifest.json eines Eintrags als Array — protokolliert mit Zweck.
*
* @return array<string, mixed>
*/
public function manifest(ArchiveEntry $entry, User $admin, string $purpose): array
{
$this->logAccess($entry, $admin, 'manifest', $purpose);
$temp = $this->tempFile($this->plaintext($entry));
try {
$zip = new ZipArchive;
if ($zip->open($temp) !== true) {
throw new RuntimeException('Beweisarchiv: Payload ist kein lesbares ZIP.');
}
$json = $zip->getFromName('manifest.json');
$zip->close();
} finally {
@unlink($temp);
}
return is_string($json) ? (array) json_decode($json, true) : [];
}
/**
* Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und
* einer HMAC-Signatur über den Hash (mit dem Archivschlüssel).
*/
public function export(ArchiveEntry $entry, User $admin, string $purpose): string
{
$this->logAccess($entry, $admin, 'export', $purpose);
$payload = $this->plaintext($entry);
return $this->buildZip([
'entry_id' => $entry->getKey(),
'event' => $entry->event->value,
'subject' => $entry->subject_type.'#'.$entry->subject_id,
'press_release_id' => $entry->press_release_id,
'legal_request_id' => $entry->legal_request_id,
'reason_category' => $entry->reason_category->value,
'created_at' => $entry->created_at?->toIso8601String(),
'retain_until' => $entry->retain_until->toDateString(),
'legal_hold' => $entry->legal_hold,
'payload_sha256' => $entry->payload_hash,
'exported_at' => now()->toIso8601String(),
'exported_by' => $admin->getKey(),
], [
'payload.zip' => $payload,
'signature.txt' => 'sha256='.hash('sha256', $payload)."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, (string) $this->keyBytes())."\n",
], 'metadata.json');
}
/**
* Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht
* den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der
* Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird
* abgelehnt (N02).
*/
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
DB::transaction(function () use ($entry, $admin, $note): void {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned()) {
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.');
}
$locked->forceFill([
'legal_hold' => true,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
])->save();
});
$entry->refresh();
}
/**
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
*/
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
DB::transaction(function () use ($entry, $admin, $note): void {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned()) {
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).');
}
$locked->forceFill([
'legal_hold' => false,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
])->save();
});
$entry->refresh();
}
/**
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
* Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge
* werden gezählt und beim nächsten Lauf wiederholt.
*
* @return array{due: int, purged: int, failed: int}
*/
public function purgeDue(bool $dryRun = false): array
{
$result = ['due' => 0, 'purged' => 0, 'failed' => 0];
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void {
foreach ($entries as $entry) {
$result['due']++;
if ($dryRun) {
continue;
}
try {
if ($this->tombstone($entry)) {
$result['purged']++;
}
} catch (Throwable $exception) {
$result['failed']++;
Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [
'entry_id' => $entry->getKey(),
'error' => $exception->getMessage(),
]);
}
}
});
return $result;
}
/**
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
* atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden.
*/
public function exportTombstones(): int
{
$rows = ArchiveEntry::query()->whereNotNull('deleted_at')->orderBy('id')
->get(['id', 'payload_path', 'deleted_at'])
->map(fn (ArchiveEntry $entry): array => [
'id' => $entry->getKey(),
'payload_path' => $entry->payload_path,
'deleted_at' => $entry->deleted_at?->toIso8601String(),
])
->values()
->all();
$path = (string) config('archive.tombstone_export_path');
$temp = $path.'.tmp';
$json = (string) json_encode([
'exported_at' => now()->toIso8601String(),
'entries' => $rows,
], JSON_PRETTY_PRINT);
if (! $this->disk()->put($temp, $json)) {
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
}
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
$this->disk()->delete($temp);
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
}
return count($rows);
}
/**
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
* Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder
* auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell"
* ab (5.1 c).
*
* @return array{re_tombstoned: int, files_removed: int, failed: int}
*/
public function reconcileAfterRestore(): array
{
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0];
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
$rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : [];
$handled = [];
foreach ($rows as $row) {
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) {
$handled[$path] = true;
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
}
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
if ($entry !== null && ! $entry->isTombstoned()) {
$entry->forceFill(['deleted_at' => now()])->save();
$result['re_tombstoned']++;
}
}
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id')
->chunkById(500, function ($entries) use (&$result, &$handled): void {
foreach ($entries as $entry) {
$path = (string) $entry->payload_path;
if (isset($handled[$path]) || ! $this->disk()->exists($path)) {
continue;
}
$handled[$path] = true;
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
}
});
if ($result['failed'] > 0) {
Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]);
}
return $result;
}
/**
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback,
* liegengebliebene .tmp) nach Karenzzeit entfernen (N11).
*
* @return array{scanned: int, orphans: int, removed: int}
*/
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
{
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
$threshold = now()->subHours($olderThanHours)->getTimestamp();
foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) {
$result['scanned'] += count($chunk);
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
foreach (array_diff($chunk, $known) as $file) {
if ($this->disk()->lastModified($file) > $threshold) {
continue;
}
$result['orphans']++;
if (! $dryRun && FileDeletion::delete($this->disk(), $file)) {
$result['removed']++;
}
}
}
return $result;
}
/**
* Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann
* Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr
* fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone).
*/
private function tombstone(ArchiveEntry $entry): bool
{
$done = DB::transaction(function () use ($entry): bool {
$locked = $this->lockedEntry($entry);
if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) {
return false;
}
if ($locked->payload_path !== null) {
FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv');
}
$this->purgeApplicationCopies($locked);
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
// wiederfindet; der Inhalt ist weg.
$locked->forceFill(['deleted_at' => now()])->save();
return true;
});
if ($done) {
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
}
return $done;
}
/**
* Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die
* Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich
* gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie.
*/
private function purgeApplicationCopies(ArchiveEntry $entry): void
{
if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true)
|| $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) {
return;
}
$image = PressReleaseImage::withTrashed()->find($entry->subject_id);
if ($image !== null && ! $image->trashed()) {
return;
}
ImageRightsConfirmation::query()
->where('subject_type', $entry->subject_type)
->where('subject_id', $entry->subject_id)
->get()
->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive());
ImageDeclarationCorrection::query()
->where('press_release_image_id', $entry->subject_id)
->get()
->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive());
$image?->forceDelete();
}
private function lockedEntry(ArchiveEntry $entry): ArchiveEntry
{
return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail();
}
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
{
$purpose = trim($purpose);
if ($purpose === '') {
throw new RuntimeException('Beweisarchiv: Zugriff ohne Zweck ist nicht vorgesehen.');
}
ArchiveAccessLog::query()->create([
'entry_id' => $entry->getKey(),
'admin_id' => $admin->getKey(),
'action' => $action,
'purpose' => Str::limit($purpose, 490, ''),
'accessed_at' => now(),
]);
}
private function plaintext(ArchiveEntry $entry): string
{
if ($entry->isTombstoned() || $entry->payload_path === null) {
throw new RuntimeException('Beweisarchiv: Eintrag ist nach Fristablauf gelöscht (Tombstone).');
}
$cipher = $this->disk()->get($entry->payload_path);
if (! is_string($cipher) || $cipher === '') {
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
}
$zip = (string) $this->encrypter()->decrypt($cipher, false);
if (! hash_equals((string) $entry->payload_hash, hash('sha256', $zip))) {
throw new RuntimeException('Beweisarchiv: Hash des Payloads stimmt nicht mit dem Eintrag überein.');
}
return $zip;
}
/**
* @param array<string, mixed> $manifest
* @param array<string, string> $files
*/
private function buildZip(array $manifest, array $files, string $manifestName = 'manifest.json'): string
{
$temp = $this->tempFile('');
$zip = new ZipArchive;
try {
if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Beweisarchiv: ZIP konnte nicht erzeugt werden.');
}
$zip->addFromString($manifestName, (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
foreach ($files as $name => $bytes) {
$zip->addFromString($name, $bytes);
}
$zip->close();
return (string) file_get_contents($temp);
} finally {
@unlink($temp);
}
}
private function tempFile(string $contents): string
{
$temp = tempnam(sys_get_temp_dir(), 'pk-archive-');
if ($temp === false) {
throw new RuntimeException('Beweisarchiv: keine temporäre Datei verfügbar.');
}
file_put_contents($temp, $contents);
return $temp;
}
private function encrypter(): Encrypter
{
$key = $this->keyBytes();
if ($key === null) {
throw new RuntimeException('Beweisarchiv: ARCHIVE_KEY fehlt.');
}
return new Encrypter($key, (string) config('archive.cipher', 'AES-256-CBC'));
}
private function keyBytes(): ?string
{
$key = (string) config('archive.key');
if ($key === '') {
return null;
}
if (Str::startsWith($key, 'base64:')) {
$key = base64_decode(substr($key, 7), true) ?: '';
}
return strlen($key) === 32 ? $key : null;
}
private function disk(): Filesystem
{
return Storage::disk((string) config('archive.disk', 'archive'));
}
}