archive: Beweisarchiv — verschlüsselte Einträge, Legal Hold, Purge mit Tombstone, Restore-Abgleich, Auslöser und Admin-Seite (Bildkennzeichnung Block F)

- archive_entries (append-only) + archive_access_log; Disk archive außerhalb des Web-Roots; eigener ARCHIVE_KEY (archive:key), AES-256-CBC, SHA-256 am Eintrag
- EvidenceArchive: record/open/manifest/export (signiertes ZIP), setLegalHold/releaseLegalHold (Frist läuft neu), purgeDue, exportTombstones, reconcileAfterRestore
- PressReleaseArchiver: image_removed (Original, Snapshot, Bestätigungen), pm_depublished (letzter veröffentlichter Stand), gdpr_change (Anonymisierung, Korrekturen), label_corrected
- Hooks in Remover, depublish/archive/changeStatusFromAdmin/deleteFromAdmin, AnonymizationService, CorrectionService, ImageCorrectionService
- Scheduler archive:purge 03:20 und archive:export-tombstones 03:30; archive:reconcile-after-restore im Runbook
- Admin ▸ Management ▸ Beweisarchiv (Liste, Legal Hold mit Pflichtvermerk, Öffnen/Export nur mit Zweck)
- Scope-Wächter: neue Befehle nutzen withoutGlobalScope(PortalScope::class)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:04:21 +00:00
parent da167b713b
commit b5b70416f2
30 changed files with 1746 additions and 6 deletions

View file

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): Tombstone-Stand täglich auf die Archiv-Disk
* exportieren, damit er einen Datenbank-Restore überlebt.
*/
class ArchiveExportTombstones extends Command
{
protected $signature = 'archive:export-tombstones';
protected $description = 'Exportiert die gelöschten Beweisarchiv-Einträge als Tombstone-Liste';
public function handle(EvidenceArchive $archive): int
{
$count = $archive->exportTombstones();
$this->info("Tombstone-Export geschrieben: {$count} Einträge.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* Erzeugt einen Schlüssel für ARCHIVE_KEY (32 Byte, base64). Der Schlüssel
* gehört in die Ploi-Umgebung und in eine getrennte Sicherung — ohne ihn
* ist das Archiv unlesbar.
*/
class ArchiveKey extends Command
{
protected $signature = 'archive:key';
protected $description = 'Gibt einen neuen ARCHIVE_KEY aus (nicht automatisch gesetzt)';
public function handle(): int
{
$this->line('ARCHIVE_KEY=base64:'.base64_encode(random_bytes(32)));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): Einträge nach Fristablauf ohne legal_hold
* löschen — Payload weg, Tombstone bleibt. Läuft täglich.
*/
class ArchivePurge extends Command
{
protected $signature = 'archive:purge {--dry-run : Nur zählen}';
protected $description = 'Löscht fällige Beweisarchiv-Einträge (Payload weg, Tombstone bleibt)';
public function handle(EvidenceArchive $archive): int
{
$count = $archive->purgeDue((bool) $this->option('dry-run'));
$this->info(($this->option('dry-run') ? 'Fällig (Trockenlauf): ' : 'Gelöscht: ').$count.' Archiveinträge.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was
* laut Tombstone-Export bereits gelöscht war, erneut entfernen. Teil des
* Restore-Runbooks (dev/betrieb/deploy-runbook.md).
*/
class ArchiveReconcileAfterRestore extends Command
{
protected $signature = 'archive:reconcile-after-restore';
protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports';
public function handle(EvidenceArchive $archive): int
{
$result = $archive->reconcileAfterRestore();
$this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt.', $result['re_tombstoned'], $result['files_removed']));
return self::SUCCESS;
}
}

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseStatusLog;
use App\Scopes\PortalScope;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@ -22,7 +23,7 @@ class BackfillPublishedEver extends Command
public function handle(): int
{
$candidates = PressRelease::withoutGlobalScopes()
$candidates = PressRelease::withoutGlobalScope(PortalScope::class)
->whereNull('published_ever_at')
->where(function ($query): void {
$query->whereNotNull('published_at')

View file

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Enums\PressReleaseStatus;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
@ -36,7 +37,7 @@ class PurgeInactiveLegacyPressReleaseImages extends Command
$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));
->whereHas('pressRelease', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->whereIn('press_releases.status', $inactive));
$byStatus = (clone $query)
->join('press_releases', 'press_releases.id', '=', 'press_release_images.press_release_id')

View file

@ -0,0 +1,26 @@
<?php
namespace App\Enums;
/**
* Auslöser eines Archiveintrags (Beweisarchiv, Decision-Update 4.1/4.2).
*/
enum ArchiveEvent: string
{
case ImageRemoved = 'image_removed';
case ImageReplaced = 'image_replaced';
case PressReleaseDepublished = 'pm_depublished';
case GdprChange = 'gdpr_change';
case LabelCorrected = 'label_corrected';
public function label(): string
{
return match ($this) {
self::ImageRemoved => 'Bild entfernt',
self::ImageReplaced => 'Bild ersetzt',
self::PressReleaseDepublished => 'Meldung depubliziert',
self::GdprChange => 'DSGVO-Änderung',
self::LabelCorrected => 'Kennzeichnung korrigiert',
};
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Enums;
/**
* Grund der Entfernung als Kategorie (Beweisarchiv 4.2) — nie als Freitext
* mit Altwerten, die gehören in den verschlüsselten Payload.
*/
enum ArchiveReasonCategory: string
{
case CustomerRequest = 'customer_request';
case ReportProcedure = 'report_procedure';
case Gdpr = 'gdpr';
case TermEnd = 'term_end';
case OwnDecision = 'own_decision';
case Correction = 'correction';
public function label(): string
{
return match ($this) {
self::CustomerRequest => 'Antrag des Kunden',
self::ReportProcedure => 'Meldeverfahren',
self::Gdpr => 'DSGVO',
self::TermEnd => 'Laufzeitende',
self::OwnDecision => 'Eigene Entscheidung',
self::Correction => 'Korrektur',
};
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\ArchiveEntry;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Öffnen und Export von Archiveinträgen (Beweisarchiv F.2): ohne Zweck
* zeigt die Route ein kleines Formular, mit Zweck kommt der Download und
* der Zugriff steht im Protokoll.
*/
class ArchiveEntryController extends Controller
{
public function __construct(private readonly EvidenceArchive $archive) {}
public function download(Request $request, ArchiveEntry $entry): Response|View
{
return $this->serve($request, $entry, 'download');
}
public function export(Request $request, ArchiveEntry $entry): Response|View
{
return $this->serve($request, $entry, 'export');
}
private function serve(Request $request, ArchiveEntry $entry, string $action): Response|View
{
abort_if($entry->isTombstoned(), 410, 'Der Eintrag ist nach Fristablauf gelöscht (Tombstone).');
$purpose = trim((string) $request->input('purpose', ''));
if ($purpose === '') {
return view('admin.archive.purpose', ['entry' => $entry, 'action' => $action]);
}
$bytes = $action === 'export'
? $this->archive->export($entry, $request->user(), $purpose)
: $this->archive->open($entry, $request->user(), $purpose);
$name = sprintf('beweisarchiv-%d-%s.zip', $entry->getKey(), $action === 'export' ? 'export' : 'payload');
return response($bytes, 200, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="'.$name.'"',
'Cache-Control' => 'private, no-store',
'X-Content-Type-Options' => 'nosniff',
]);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Lesender Zugriff auf einen Archiveintrag (Beweisarchiv 4.3): wer, wann,
* wozu. Insert-only.
*/
class ArchiveAccessLog extends Model
{
public $timestamps = false;
protected $table = 'archive_access_log';
protected $fillable = ['entry_id', 'admin_id', 'action', 'purpose', 'accessed_at'];
protected function casts(): array
{
return ['accessed_at' => 'datetime'];
}
protected static function booted(): void
{
static::updating(fn () => throw new \LogicException('archive_access_log ist insert-only.'));
static::deleting(fn () => throw new \LogicException('archive_access_log wird nicht gelöscht.'));
}
/**
* @return BelongsTo<ArchiveEntry, $this>
*/
public function entry(): BelongsTo
{
return $this->belongsTo(ArchiveEntry::class, 'entry_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}

112
app/Models/ArchiveEntry.php Normal file
View file

@ -0,0 +1,112 @@
<?php
namespace App\Models;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use LogicException;
/**
* Eintrag im Beweisarchiv (Bildkennzeichnung Block F). Append-only: Nur
* legal_hold-Felder, retain_until (bei Aufhebung) und der Tombstone dürfen
* sich ändern; alles andere wirft. Löschen von Zeilen ist ausgeschlossen —
* der Tombstone bleibt.
*/
class ArchiveEntry extends Model
{
public const UPDATED_AT = null;
/** @var list<string> */
private const MUTABLE = ['legal_hold', 'legal_hold_note', 'legal_hold_set_by', 'legal_hold_set_at', 'retain_until', 'deleted_at', 'payload_path'];
protected $fillable = [
'subject_type',
'subject_id',
'press_release_id',
'event',
'actor_type',
'actor_id',
'reason_category',
'summary',
'payload_path',
'payload_hash',
'payload_bytes',
'retain_until',
'legal_hold',
'legal_hold_note',
'legal_hold_set_by',
'legal_hold_set_at',
'deleted_at',
'created_at',
];
protected function casts(): array
{
return [
'event' => ArchiveEvent::class,
'reason_category' => ArchiveReasonCategory::class,
'summary' => 'array',
'retain_until' => 'date',
'legal_hold' => 'boolean',
'legal_hold_set_at' => 'datetime',
'deleted_at' => 'datetime',
'created_at' => 'datetime',
];
}
protected static function booted(): void
{
static::updating(function (self $entry): void {
$illegal = array_diff(array_keys($entry->getDirty()), self::MUTABLE);
if ($illegal !== []) {
throw new LogicException('archive_entries ist append-only; geändert werden dürfen nur legal_hold, retain_until und der Tombstone (versucht: '.implode(', ', $illegal).').');
}
});
static::deleting(fn () => throw new LogicException('archive_entries werden nie gelöscht; der Tombstone bleibt.'));
}
public function isTombstoned(): bool
{
return $this->deleted_at !== null;
}
/**
* Frist: 31.12. des Bezugsjahres plus die konfigurierten Jahre.
*/
public static function retentionEndFor(\DateTimeInterface $reference): Carbon
{
return Carbon::instance($reference)->endOfYear()->addYears((int) config('archive.retention_years', 3))->startOfDay();
}
/**
* @param Builder<ArchiveEntry> $query
* @return Builder<ArchiveEntry>
*/
public function scopeDueForPurge(Builder $query): Builder
{
return $query->whereNull('deleted_at')->where('legal_hold', false)->whereDate('retain_until', '<', today());
}
/**
* @return MorphTo<Model, $this>
*/
public function subject(): MorphTo
{
return $this->morphTo();
}
/**
* @return HasMany<ArchiveAccessLog, $this>
*/
public function accessLog(): HasMany
{
return $this->hasMany(ArchiveAccessLog::class, 'entry_id');
}
}

View file

@ -0,0 +1,393 @@
<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\User;
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 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.
*/
class EvidenceArchive
{
public function isConfigured(): bool
{
return $this->keyBytes() !== null;
}
/**
* @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 = [],
): ?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,
'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());
if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
}
return ArchiveEntry::query()->create([
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'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(),
]);
}
/**
* 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,
'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');
}
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
$entry->forceFill([
'legal_hold' => true,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
])->save();
}
/**
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
*/
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
$entry->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();
}
/**
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
*/
public function purgeDue(bool $dryRun = false): int
{
$count = 0;
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void {
foreach ($entries as $entry) {
$count++;
if ($dryRun) {
continue;
}
$this->tombstone($entry);
}
});
return $count;
}
/**
* Tombstone-Stand exportieren, damit er einen Restore überlebt.
*/
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();
$this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([
'exported_at' => now()->toIso8601String(),
'entries' => $rows,
], JSON_PRETTY_PRINT));
return count($rows);
}
/**
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
*
* @return array{re_tombstoned: int, files_removed: int}
*/
public function reconcileAfterRestore(): array
{
$result = ['re_tombstoned' => 0, 'files_removed' => 0];
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
if (! is_string($raw) || $raw === '') {
return $result;
}
$rows = (array) (json_decode($raw, true)['entries'] ?? []);
foreach ($rows as $row) {
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
if ($path !== null && $this->disk()->exists($path)) {
$this->disk()->delete($path);
$result['files_removed']++;
}
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
if ($entry !== null && ! $entry->isTombstoned()) {
$entry->forceFill(['deleted_at' => now()])->save();
$result['re_tombstoned']++;
}
}
return $result;
}
private function tombstone(ArchiveEntry $entry): void
{
DB::transaction(function () use ($entry): void {
if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) {
$this->disk()->delete($entry->payload_path);
}
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
// wiederfindet; der Inhalt ist weg.
$entry->forceFill(['deleted_at' => now()])->save();
});
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
}
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'));
}
}

View file

@ -0,0 +1,214 @@
<?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\Support\ImageCredit;
use Illuminate\Support\Facades\Storage;
/**
* 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.
*/
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): ?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 = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
if (is_string($sidecar) && $disk->exists($sidecar)) {
$files['published.json'] = (string) $disk->get($sidecar);
}
}
$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', 'created_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,
],
$files,
['image_id' => $image->getKey(), 'files' => count($files)],
);
}
/**
* Meldung aus dem öffentlichen Angebot genommen: letzter veröffentlichter
* Stand mit Text, Bildern (Snapshots), Kontaktfeldern, Einreicher, Zeiten.
*/
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null): ?ArchiveEntry
{
$pressRelease->loadMissing(['company', 'user', 'contacts', 'images']);
$files = [];
$images = [];
foreach ($pressRelease->images as $image) {
$entry = [
'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,
];
if ($image->published_snapshot_path !== null) {
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
if ($disk->exists($image->published_snapshot_path)) {
$name = 'image-'.$image->getKey().'.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION);
$files[$name] = (string) $disk->get($image->published_snapshot_path);
$entry['file'] = $name;
}
}
$images[] = $entry;
}
return $this->archive->record(
ArchiveEvent::PressReleaseDepublished,
$pressRelease,
$pressRelease->getKey(),
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'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']))->all(),
'images' => $images,
'note' => $note,
],
$files,
['images' => count($images), 'files' => count($files)],
);
}
/**
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
* den Antrag.
*
* @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): ?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' => $legalRequest ? [
'id' => $legalRequest->getKey(),
'reference' => $legalRequest->reference(),
'type' => $legalRequest->type->value,
'requester_email' => $legalRequest->requester_email,
] : null,
'note' => $note,
],
[],
['fields' => count(array_keys($after)), 'legal_request_id' => $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>
*/
private function pressReleaseHeader(PressRelease $pressRelease): array
{
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), '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';
}
}

View file

@ -3,10 +3,12 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveReasonCategory;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseRedactionSnapshot;
use App\Services\ActivityLog;
use App\Services\Archive\PressReleaseArchiver;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
@ -152,6 +154,19 @@ class AnonymizationService
$this->forgetSitemapCaches();
// Beweisarchiv (Block F.4): Diff alt/neu mit Antrag — dort dürfen die
// Altwerte liegen (verschlüsselt, zugriffsprotokolliert), im
// Aktivitätsverlauf nicht.
app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
(array) ($snapshot->encrypted_before['fields'] ?? []),
$pressRelease->fresh()?->only(self::REDACTABLE_FIELDS) ?? [],
$legalRequest,
Auth::user(),
ArchiveReasonCategory::Gdpr,
'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($contactIdsForLog = $snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)',
);
// Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die
// personenbezogenen Daten im Verlauf weiter.
$this->activityLog->record(

View file

@ -3,6 +3,7 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveReasonCategory;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
@ -11,6 +12,7 @@ use App\Models\PressRelease;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
use App\Support\WebCaches;
@ -148,6 +150,8 @@ class CorrectionService
$this->assertNotRateLimited($pressRelease);
$before = $pressRelease->only(self::CORRECTABLE_FIELDS);
$legalRequest = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): LegalRequest {
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $pressRelease->id,
@ -178,6 +182,16 @@ class CorrectionService
WebCaches::forgetSitemaps();
app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
$before,
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
'Kleine Korrektur (Selbstbedienung)',
);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
->keys()
@ -231,6 +245,8 @@ class CorrectionService
return false;
}
$before = $pressRelease->only(self::CORRECTABLE_FIELDS);
DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): void {
$legalRequest->forceFill([
'status' => LegalRequestStatus::Resolved->value,
@ -249,6 +265,16 @@ class CorrectionService
WebCaches::forgetSitemaps();
app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
$before,
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
'Kostenpflichtige Änderung',
);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
->keys()

View file

@ -7,6 +7,7 @@ use App\Enums\AiImageDeclaration;
use App\Models\ImageDeclarationCorrection;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\PressReleaseArchiver;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
@ -18,7 +19,10 @@ use Illuminate\Support\Facades\DB;
*/
class ImageCorrectionService
{
public function __construct(private readonly PublishedImageSnapshot $snapshots) {}
public function __construct(
private readonly PublishedImageSnapshot $snapshots,
private readonly PressReleaseArchiver $archiver,
) {}
public function correctCredit(PressReleaseImage $image, User $admin, string $newCredit, string $reason): ImageDeclarationCorrection
{
@ -71,12 +75,15 @@ class ImageCorrectionService
});
// Öffentliche Fassung hat sich geändert: Snapshot nachziehen und
// Seiten-Caches verwerfen (D.5).
// Seiten-Caches verwerfen (D.5). Jede Korrektur zusätzlich als
// Archiveintrag label_corrected (Block F.4).
if ($image->wasEverPublished()) {
$this->snapshots->capture($image->fresh());
WebCaches::forgetPublicPages();
}
$this->archiver->labelCorrected($image->fresh(), $correction, $admin);
return $correction;
}
}

View file

@ -2,7 +2,11 @@
namespace App\Services\PressRelease\Images;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Support\WebCaches;
use Illuminate\Support\Facades\DB;
@ -22,13 +26,26 @@ use Illuminate\Support\Facades\Storage;
*/
class PressReleaseImageRemover
{
public function __construct(private readonly ImageService $imageService) {}
public function __construct(
private readonly ImageService $imageService,
private readonly PressReleaseArchiver $archiver,
) {}
public function remove(PressReleaseImage $image): void
public function remove(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest): void
{
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
if ($image->wasEverPublished() || $image->isLegacyRules()) {
// Veröffentlicht: Original, Snapshot und Bestätigungen ins
// Beweisarchiv (Block F), danach die lokalen Beweisdateien weg.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
$entry = $this->archiver->imageRemoved($image, $actor, $event, $reason);
if ($entry !== null) {
$this->deleteEvidenceFiles($image);
}
}
$image->delete();
WebCaches::forgetPublicPages();
@ -51,4 +68,21 @@ class PressReleaseImageRemover
$image->forceDelete();
});
}
private function deleteEvidenceFiles(PressReleaseImage $image): void
{
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
foreach (array_filter([$image->original_path, $image->published_snapshot_path]) as $path) {
$disk->delete($path);
}
if ($image->published_snapshot_path !== null) {
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
if (is_string($sidecar)) {
$disk->delete($sidecar);
}
}
}
}

View file

@ -3,6 +3,7 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveReasonCategory;
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseClassification;
use App\Enums\PressReleaseStatus;
@ -23,6 +24,7 @@ use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Mail\EmailSuppressionService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
@ -557,6 +559,7 @@ class PressReleaseService
// Seiten-Caches.
WebCaches::forgetPublicPages();
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision);
}
/**
@ -586,10 +589,17 @@ class PressReleaseService
$pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders');
WebCaches::forgetPublicPages();
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, 'Depublikation auf Wunsch des Absenders (kostenpflichtige Serviceleistung)', 'depublication');
$this->archiveDepublished(
$pressRelease,
$syncLockReason !== null ? ArchiveReasonCategory::ReportProcedure : ArchiveReasonCategory::CustomerRequest,
$syncLockReason,
);
}
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;
DB::transaction(function () use ($pressRelease, $status, $reason): void {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
@ -630,12 +640,17 @@ class PressReleaseService
if ($status === PressReleaseStatus::Published) {
$this->capturePublishedImages($pressRelease);
} elseif ($previous === PressReleaseStatus::Published) {
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, $reason);
}
}
public function deleteFromAdmin(PressRelease $pressRelease): void
{
if ($pressRelease->status === PressReleaseStatus::Published) {
// Letzter veröffentlichter Stand VOR dem Überschreiben ins Archiv.
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, 'Entfernung durch den Admin');
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'text' => AdminPreset::activeValue(
@ -662,6 +677,15 @@ class PressReleaseService
$pressRelease->delete();
}
/**
* Beweisarchiv (Block F.4): letzter veröffentlichter Stand, sobald eine
* Meldung das öffentliche Angebot verlässt.
*/
private function archiveDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null): void
{
app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note);
}
/**
* Veröffentlichungsgate (E.1): Bilder aus Formular v2 brauchen eine
* Bestätigung zur aktuellen Datei — sonst wartet ein Admin-Upload noch