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

@ -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()