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:
parent
da167b713b
commit
b5b70416f2
30 changed files with 1746 additions and 6 deletions
25
app/Console/Commands/ArchiveExportTombstones.php
Normal file
25
app/Console/Commands/ArchiveExportTombstones.php
Normal 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;
|
||||
}
|
||||
}
|
||||
24
app/Console/Commands/ArchiveKey.php
Normal file
24
app/Console/Commands/ArchiveKey.php
Normal 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;
|
||||
}
|
||||
}
|
||||
26
app/Console/Commands/ArchivePurge.php
Normal file
26
app/Console/Commands/ArchivePurge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
app/Console/Commands/ArchiveReconcileAfterRestore.php
Normal file
27
app/Console/Commands/ArchiveReconcileAfterRestore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
26
app/Enums/ArchiveEvent.php
Normal file
26
app/Enums/ArchiveEvent.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/Enums/ArchiveReasonCategory.php
Normal file
29
app/Enums/ArchiveReasonCategory.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Admin/ArchiveEntryController.php
Normal file
54
app/Http/Controllers/Admin/ArchiveEntryController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
app/Models/ArchiveAccessLog.php
Normal file
46
app/Models/ArchiveAccessLog.php
Normal 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
112
app/Models/ArchiveEntry.php
Normal 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');
|
||||
}
|
||||
}
|
||||
393
app/Services/Archive/EvidenceArchive.php
Normal file
393
app/Services/Archive/EvidenceArchive.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
214
app/Services/Archive/PressReleaseArchiver.php
Normal file
214
app/Services/Archive/PressReleaseArchiver.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue