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
|
|
@ -275,3 +275,12 @@ TRANSPARENCY_FREE_PORTALS_CHECKED_AT=
|
|||
# press-releases:hash-content → press-releases:assign-canonicals.
|
||||
CANONICAL_ASSIGNMENT_ENABLED=true
|
||||
CANONICAL_FALLBACK=first
|
||||
|
||||
# --- Bildkennzeichnung (Formular v2) und Beweisarchiv (Block F) ---
|
||||
# IMAGE_RIGHTS_V2: Formular v2 im Bildmanager und in der API; false = Verhalten wie vor der Umstellung.
|
||||
IMAGE_RIGHTS_V2=false
|
||||
# Eigener Schlüssel für die IP-Ableitung im Bestätigungsprotokoll (Fallback APP_KEY).
|
||||
CONFIRMATION_IP_KEY=
|
||||
# Beweisarchiv: eigener Schlüssel (php artisan archive:key), getrennt sichern — ohne ihn ist das Archiv unlesbar.
|
||||
ARCHIVE_KEY=
|
||||
ARCHIVE_RETENTION_YEARS=3
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
31
config/archive.php
Normal file
31
config/archive.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Getrennter, verschlüsselter Datensatz zu jeder entfernten Veröffentlichung
|
||||
| (Bild entfernt oder ersetzt, Meldung depubliziert, DSGVO-Änderung,
|
||||
| Kennzeichnung korrigiert). Zweck: Nachweis und Verteidigung gegenüber
|
||||
| Rechteinhabern, Betroffenen und Behörden (Art. 6 Abs. 1 lit. f, bei
|
||||
| Löschanträgen Art. 17 Abs. 3 lit. e DSGVO).
|
||||
|
|
||||
| key: eigener Schlüssel (base64:…), NICHT der APP_KEY — ein
|
||||
| Schlüsselwechsel der App darf das Archiv nicht berühren. Ohne Schlüssel
|
||||
| wird nichts archiviert und das Betriebsprotokoll meldet es.
|
||||
|
|
||||
| retention_years: Frist ab dem 31.12. des Ereignisjahres (Decision-Update
|
||||
| E3). legal_hold hat Vorrang; nach Aufhebung läuft die Frist neu.
|
||||
|
|
||||
*/
|
||||
|
||||
'disk' => env('ARCHIVE_DISK', 'archive'),
|
||||
'key' => env('ARCHIVE_KEY'),
|
||||
'cipher' => 'AES-256-CBC',
|
||||
'retention_years' => (int) env('ARCHIVE_RETENTION_YEARS', 3),
|
||||
'tombstone_export_path' => 'tombstones/latest.json',
|
||||
|
||||
];
|
||||
|
|
@ -47,6 +47,16 @@ return [
|
|||
'report' => false,
|
||||
],
|
||||
|
||||
// Beweisarchiv (Bildkennzeichnung Block F): außerhalb des Web-Roots,
|
||||
// Inhalte liegen verschlüsselt (config/archive.php), nie öffentlich.
|
||||
'archive' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/archive'),
|
||||
'serve' => false,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update 4.2): ein Eintrag
|
||||
* je Ereignis mit Verweis auf den verschlüsselten Payload im Archiv-Storage.
|
||||
*
|
||||
* Append-only: Es gibt nur Anlegen, Tombstone (deleted_at, Payload weg,
|
||||
* Zeile bleibt ohne Inhalt) und legal_hold setzen/aufheben. Das Audit-Log
|
||||
* der Anwendung verweist nur auf die id, nie auf Altwerte.
|
||||
*
|
||||
* `archive_access_log`: jeder lesende Zugriff mit Admin, Zeitpunkt und
|
||||
* Pflichtangabe des Zwecks.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('archive_entries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('subject_type', 80);
|
||||
$table->unsignedBigInteger('subject_id');
|
||||
$table->unsignedBigInteger('press_release_id')->nullable();
|
||||
$table->string('event', 30);
|
||||
$table->string('actor_type', 20);
|
||||
$table->unsignedBigInteger('actor_id')->nullable();
|
||||
$table->string('reason_category', 30);
|
||||
$table->json('summary')->nullable();
|
||||
$table->string('payload_path', 512)->nullable();
|
||||
$table->char('payload_hash', 64)->nullable();
|
||||
$table->unsignedBigInteger('payload_bytes')->nullable();
|
||||
$table->date('retain_until');
|
||||
$table->boolean('legal_hold')->default(false);
|
||||
$table->string('legal_hold_note', 1000)->nullable();
|
||||
$table->unsignedBigInteger('legal_hold_set_by')->nullable();
|
||||
$table->timestamp('legal_hold_set_at')->nullable();
|
||||
$table->timestamp('deleted_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['subject_type', 'subject_id'], 'archive_entries_subject_idx');
|
||||
$table->index(['press_release_id'], 'archive_entries_press_release_idx');
|
||||
$table->index(['retain_until', 'legal_hold', 'deleted_at'], 'archive_entries_purge_idx');
|
||||
$table->index(['event', 'created_at'], 'archive_entries_event_created_idx');
|
||||
});
|
||||
|
||||
Schema::create('archive_access_log', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('entry_id')->constrained('archive_entries')->cascadeOnDelete();
|
||||
$table->foreignId('admin_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('action', 20);
|
||||
$table->string('purpose', 500);
|
||||
$table->timestamp('accessed_at');
|
||||
|
||||
$table->index(['entry_id', 'accessed_at'], 'archive_access_entry_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('archive_access_log');
|
||||
Schema::dropIfExists('archive_entries');
|
||||
}
|
||||
};
|
||||
|
|
@ -288,6 +288,14 @@ Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo
|
|||
(`config:cache` … `queue:restart`).
|
||||
- Migrationen sind additiv gehalten; kein automatisches `migrate:rollback` in Prod —
|
||||
im Fehlerfall DB-Backup einspielen (Backup **vor** jedem Deploy mit Migrationen!).
|
||||
- **Nach jedem DB-Restore:** `php artisan archive:reconcile-after-restore` ausführen.
|
||||
Das Beweisarchiv (Bildkennzeichnung Block F) löscht Einträge nach Frist; eine
|
||||
eingespielte Sicherung würde gelöschte Zeilen und Payloads zurückbringen. Der
|
||||
Abgleich liest den täglich exportierten Tombstone-Stand
|
||||
(`storage/app/archive/tombstones/latest.json`) und entfernt sie erneut.
|
||||
- `ARCHIVE_KEY` (Beweisarchiv) und `CONFIRMATION_IP_KEY` liegen nur in der
|
||||
Ploi-Umgebung. `ARCHIVE_KEY` getrennt vom DB-Backup sichern — ohne ihn ist das
|
||||
Archiv unlesbar; erzeugen mit `php artisan archive:key`.
|
||||
|
||||
## 10. Betrieb nach Go-Live (Stand 20.07.2026 — System ist LIVE)
|
||||
|
||||
|
|
|
|||
13
resources/views/admin/archive/purpose.blade.php
Normal file
13
resources/views/admin/archive/purpose.blade.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<x-layouts.app :title="__('Zweck des Zugriffs')">
|
||||
<div class="max-w-lg space-y-4">
|
||||
<flux:heading size="xl">{{ __('Zweck des Zugriffs') }}</flux:heading>
|
||||
<flux:text>{{ __('Archiveintrag #:id (:event). Jeder Zugriff auf das Beweisarchiv wird mit Zweck protokolliert.', ['id' => $entry->id, 'event' => $entry->event->label()]) }}</flux:text>
|
||||
<form method="get" action="{{ route($action === 'export' ? 'admin.archive.export' : 'admin.archive.download', ['entry' => $entry->id]) }}" class="space-y-3">
|
||||
<flux:input name="purpose" :label="__('Zweck (Pflicht)')" :placeholder="__('z. B. Abmahnung Az. 12/2026 prüfen')" required autofocus />
|
||||
<div class="flex gap-2">
|
||||
<flux:button type="submit" variant="primary">{{ $action === 'export' ? __('Export herunterladen') : __('Payload öffnen') }}</flux:button>
|
||||
<flux:button variant="filled" href="{{ route('admin.archive.index') }}">{{ __('Abbrechen') }}</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
|
@ -142,6 +142,10 @@
|
|||
:current="request()->routeIs('admin.reports.activity')" wire:navigate>
|
||||
{{ __('Verlauf') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="archive-box" :href="route('admin.archive.index')"
|
||||
:current="request()->routeIs('admin.archive.*')" wire:navigate>
|
||||
{{ __('Beweisarchiv') }}
|
||||
</flux:navlist.item>
|
||||
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
|
||||
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
|
||||
:current="request()->routeIs('admin.contact-messages.*')"
|
||||
|
|
|
|||
176
resources/views/livewire/admin/archive/index.blade.php
Normal file
176
resources/views/livewire/admin/archive/index.blade.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
/**
|
||||
* Admin ▸ Management ▸ Beweisarchiv (Bildkennzeichnung Block F.2).
|
||||
*
|
||||
* Liste der Einträge, Legal-Hold-Schalter mit Pflichtvermerk, Öffnen und
|
||||
* Export nur mit Zweck (Zugriffsprotokoll). Inhalte werden hier nie
|
||||
* angezeigt — nur Kennzahlen; der Payload kommt als Download.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Beweisarchiv')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
#[Url]
|
||||
public string $event = '';
|
||||
|
||||
#[Url]
|
||||
public string $subject = '';
|
||||
|
||||
public ?int $holdEntryId = null;
|
||||
|
||||
public string $holdNote = '';
|
||||
|
||||
public function openHold(int $entryId): void
|
||||
{
|
||||
$this->holdEntryId = $entryId;
|
||||
$this->holdNote = '';
|
||||
$this->resetErrorBag();
|
||||
}
|
||||
|
||||
public function toggleHold(EvidenceArchive $archive): void
|
||||
{
|
||||
$entry = ArchiveEntry::query()->findOrFail((int) $this->holdEntryId);
|
||||
|
||||
$this->validate(
|
||||
['holdNote' => ['required', 'string', 'min:5', 'max:1000']],
|
||||
['holdNote.required' => __('Bitte begründen Sie das Setzen bzw. Aufheben (Pflichtvermerk).')],
|
||||
);
|
||||
|
||||
$entry->legal_hold
|
||||
? $archive->releaseLegalHold($entry, auth()->user(), $this->holdNote)
|
||||
: $archive->setLegalHold($entry, auth()->user(), $this->holdNote);
|
||||
|
||||
$this->holdEntryId = null;
|
||||
$this->holdNote = '';
|
||||
|
||||
Flux::toast(text: $entry->fresh()->legal_hold ? __('Legal Hold gesetzt.') : __('Legal Hold aufgehoben, Frist läuft neu.'), variant: 'success');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$query = ArchiveEntry::query()->orderByDesc('id');
|
||||
|
||||
if ($this->event !== '' && ArchiveEvent::tryFrom($this->event) !== null) {
|
||||
$query->where('event', $this->event);
|
||||
}
|
||||
|
||||
if ($this->subject !== '') {
|
||||
$query->where(function ($q): void {
|
||||
$q->where('press_release_id', (int) $this->subject)->orWhere('subject_id', (int) $this->subject);
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'entries' => $query->paginate(50),
|
||||
'events' => ArchiveEvent::cases(),
|
||||
'configured' => app(EvidenceArchive::class)->isConfigured(),
|
||||
'holdEntry' => $this->holdEntryId ? ArchiveEntry::query()->find($this->holdEntryId) : null,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('Beweisarchiv') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Verschlüsselte Datensätze zu entfernten Veröffentlichungen. Drei Jahre zum Jahresende, Legal Hold hat Vorrang. Jeder Zugriff wird mit Zweck protokolliert.') }}</flux:subheading>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:select wire:model.live="event" size="sm">
|
||||
<flux:select.option value="">{{ __('Alle Ereignisse') }}</flux:select.option>
|
||||
@foreach ($events as $eventOption)
|
||||
<flux:select.option :value="$eventOption->value">{{ $eventOption->label() }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model.live.debounce.400ms="subject" size="sm" :placeholder="__('PM- oder Bild-ID')" class="w-40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@unless ($configured)
|
||||
<flux:callout variant="danger" icon="exclamation-triangle">
|
||||
{{ __('ARCHIVE_KEY ist nicht gesetzt: Es wird nichts archiviert und nichts kann geöffnet werden. Schlüssel mit „php artisan archive:key“ erzeugen, in der Umgebung setzen und getrennt sichern.') }}
|
||||
</flux:callout>
|
||||
@endunless
|
||||
|
||||
<flux:card>
|
||||
<table class="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-zinc-500">
|
||||
<th class="py-2 pr-3 font-medium">#</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Ereignis') }}</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Betrifft') }}</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Grund') }}</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Angelegt') }}</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Frist bis') }}</th>
|
||||
<th class="py-2 pr-3 font-medium">{{ __('Status') }}</th>
|
||||
<th class="py-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
|
||||
@forelse ($entries as $entry)
|
||||
<tr wire:key="archive-{{ $entry->id }}">
|
||||
<td class="py-2 pr-3 font-mono text-zinc-500">{{ $entry->id }}</td>
|
||||
<td class="py-2 pr-3">{{ $entry->event->label() }}</td>
|
||||
<td class="py-2 pr-3 text-zinc-600 dark:text-zinc-300">
|
||||
{{ class_basename($entry->subject_type) }} #{{ $entry->subject_id }}
|
||||
@if ($entry->press_release_id)
|
||||
· <a class="underline underline-offset-2" href="{{ route('admin.press-releases.show', ['id' => $entry->press_release_id]) }}" wire:navigate>PM #{{ $entry->press_release_id }}</a>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 pr-3">{{ $entry->reason_category->label() }}</td>
|
||||
<td class="py-2 pr-3 whitespace-nowrap">{{ $entry->created_at?->format('d.m.Y H:i') }}</td>
|
||||
<td class="py-2 pr-3 whitespace-nowrap">{{ $entry->retain_until->format('d.m.Y') }}</td>
|
||||
<td class="py-2 pr-3">
|
||||
@if ($entry->isTombstoned())
|
||||
<flux:badge size="sm" color="zinc">{{ __('gelöscht') }}</flux:badge>
|
||||
@elseif ($entry->legal_hold)
|
||||
<flux:badge size="sm" color="red">Legal Hold</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="green">{{ __('aufbewahrt') }}</flux:badge>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 text-right whitespace-nowrap">
|
||||
@unless ($entry->isTombstoned())
|
||||
<flux:button size="xs" variant="ghost" wire:click="openHold({{ $entry->id }})">
|
||||
{{ $entry->legal_hold ? __('Hold aufheben') : __('Legal Hold') }}
|
||||
</flux:button>
|
||||
<flux:button size="xs" variant="ghost" href="{{ route('admin.archive.download', ['entry' => $entry->id]) }}">{{ __('Öffnen') }}</flux:button>
|
||||
<flux:button size="xs" variant="ghost" href="{{ route('admin.archive.export', ['entry' => $entry->id]) }}">{{ __('Export') }}</flux:button>
|
||||
@endunless
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="8" class="py-6 text-center text-zinc-500">{{ __('Noch keine Archiveinträge.') }}</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-4">{{ $entries->links() }}</div>
|
||||
</flux:card>
|
||||
|
||||
@if ($holdEntry)
|
||||
<flux:card data-hold-form>
|
||||
<flux:heading size="md">{{ $holdEntry->legal_hold ? __('Legal Hold aufheben (Eintrag #:id)', ['id' => $holdEntry->id]) : __('Legal Hold setzen (Eintrag #:id)', ['id' => $holdEntry->id]) }}</flux:heading>
|
||||
@if ($holdEntry->legal_hold_note)
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">{{ __('Letzter Vermerk: :note', ['note' => $holdEntry->legal_hold_note]) }}</flux:text>
|
||||
@endif
|
||||
<div class="mt-3 space-y-3">
|
||||
<flux:textarea wire:model="holdNote" :label="__('Pflichtvermerk (Streit, Regress, Aktenzeichen)')" rows="2" />
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="primary" wire:click="toggleHold">{{ $holdEntry->legal_hold ? __('Aufheben') : __('Setzen') }}</flux:button>
|
||||
<flux:button variant="filled" wire:click="$set('holdEntryId', null)">{{ __('Abbrechen') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\ArchiveEntryController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\LeaveImpersonationController;
|
||||
use App\Http\Controllers\Admin\PressReleaseImageEvidenceController;
|
||||
|
|
@ -129,6 +130,13 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
Volt::route('admin/roles/create', 'admin.roles.create')->name('admin.roles.create');
|
||||
Volt::route('admin/roles/{id}/edit', 'admin.roles.edit')->name('admin.roles.edit');
|
||||
|
||||
// Beweisarchiv (Bildkennzeichnung Block F): Liste, Legal Hold, Öffnen/Export mit Zweck.
|
||||
Route::middleware('can:compliance:manage')->group(function (): void {
|
||||
Volt::route('admin/archive', 'admin.archive.index')->name('admin.archive.index');
|
||||
Route::get('admin/archive/{entry}/download', [ArchiveEntryController::class, 'download'])->whereNumber('entry')->name('admin.archive.download');
|
||||
Route::get('admin/archive/{entry}/export', [ArchiveEntryController::class, 'export'])->whereNumber('entry')->name('admin.archive.export');
|
||||
});
|
||||
|
||||
// Reports
|
||||
Volt::route('admin/reports/activity', 'admin.reports.activity')->name('admin.reports.activity');
|
||||
Volt::route('admin/reports/slow-requests', 'admin.reports.slow-requests')->name('admin.reports.slow-requests');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\AggregatePressReleaseViews;
|
||||
use App\Console\Commands\ArchiveExportTombstones;
|
||||
use App\Console\Commands\ArchivePurge;
|
||||
use App\Console\Commands\CheckIndexStatus;
|
||||
use App\Console\Commands\CheckMailDomains;
|
||||
use App\Console\Commands\ExpireGrandfathering;
|
||||
|
|
@ -71,6 +73,20 @@ Schedule::command(PurgeRedactionSnapshots::class)
|
|||
->runInBackground()
|
||||
->onFailure($alertOnFailure('snapshots:purge'));
|
||||
|
||||
// Beweisarchiv (Bildkennzeichnung Block F.3): fällige Einträge löschen (03:20),
|
||||
// Tombstone-Stand exportieren (03:30), damit er einen Restore überlebt.
|
||||
Schedule::command(ArchivePurge::class)
|
||||
->dailyAt('03:20')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('archive:purge'));
|
||||
|
||||
Schedule::command(ArchiveExportTombstones::class)
|
||||
->dailyAt('03:30')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('archive:export-tombstones'));
|
||||
|
||||
// PM-Entwürfe: Zombie-Drafts nach 180 Tagen Inaktivität archivieren (wöchentlich)
|
||||
Schedule::command(PurgeExpiredPressReleaseDrafts::class, ['--days=180'])
|
||||
->weekly()
|
||||
|
|
|
|||
314
tests/Feature/Archive/EvidenceArchiveTest.php
Normal file
314
tests/Feature/Archive/EvidenceArchiveTest.php
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Beweisarchiv (Bildkennzeichnung Block F): Verschlüsselung, Append-only,
|
||||
* Legal Hold, Purge mit Tombstone, Tombstone-Export und Restore-Abgleich,
|
||||
* Auslöser (Bild entfernt, Depublikation, DSGVO-Änderung, Korrektur),
|
||||
* Zugriffsprotokoll und Admin-Zugriff.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Storage::fake('archive');
|
||||
Storage::fake('local');
|
||||
Storage::fake('public');
|
||||
Mail::fake();
|
||||
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
});
|
||||
|
||||
function archiveAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pr: PressRelease, image: PressReleaseImage}
|
||||
*/
|
||||
function archivePublishedWithImage(): array
|
||||
{
|
||||
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
|
||||
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
|
||||
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
|
||||
'published_ever_at' => now()->subDay(),
|
||||
'slug' => 'archiv-'.fake()->unique()->numberBetween(1, 99999),
|
||||
]);
|
||||
|
||||
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
|
||||
'copyright' => 'Anna Beispiel',
|
||||
'published_ever_at' => now()->subDay(),
|
||||
'published_snapshot_path' => 'press-release-published/'.$pr->id.'/snap.jpg',
|
||||
'published_snapshot_sha256' => hash('sha256', 'snapshot-bytes'),
|
||||
]);
|
||||
Storage::disk('local')->put($image->path, 'cover-bytes');
|
||||
Storage::disk('local')->put($image->original_path, 'original-bytes');
|
||||
Storage::disk('local')->put('press-release-published/'.$pr->id.'/snap.jpg', 'snapshot-bytes');
|
||||
Storage::disk('local')->put('press-release-published/'.$pr->id.'/snap.json', '{"credit_line":"Foto: Anna Beispiel"}');
|
||||
|
||||
ImageRightsConfirmation::query()->create([
|
||||
'subject_type' => $image->getMorphClass(),
|
||||
'subject_id' => $image->getKey(),
|
||||
'press_release_id' => $pr->id,
|
||||
'company_id' => $pr->company_id,
|
||||
'user_id' => $pr->user_id,
|
||||
'channel' => ConfirmationChannel::Form,
|
||||
'legal_text_key' => 'img-confirm-v2',
|
||||
'text_snapshot' => 'Ich bestätige …',
|
||||
'agb_version' => '0.6',
|
||||
'file_sha256' => $image->original_sha256,
|
||||
'declaration_snapshot' => [],
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return ['pr' => $pr, 'image' => $image->fresh()];
|
||||
}
|
||||
|
||||
test('an entry round-trips through encryption with hash check, manifest and signed export', function () {
|
||||
$admin = archiveAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
|
||||
$entry = $archive->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'admin', $admin->id, ['title' => 'Geheim'], ['bild.jpg' => 'jpeg-bytes'], ['files' => 1]);
|
||||
|
||||
expect($entry)->not->toBeNull()
|
||||
->and($entry->payload_path)->toStartWith('entries/'.now()->format('Y').'/')
|
||||
->and($entry->retain_until->toDateString())->toBe(now()->endOfYear()->addYears(3)->toDateString())
|
||||
->and(Storage::disk('archive')->get($entry->payload_path))->not->toContain('Geheim');
|
||||
|
||||
$manifest = $archive->manifest($entry, $admin, 'Test');
|
||||
expect($manifest['title'])->toBe('Geheim')
|
||||
->and($manifest['event'])->toBe('pm_depublished');
|
||||
|
||||
$zip = $archive->open($entry, $admin, 'Abmahnung prüfen');
|
||||
expect(hash('sha256', $zip))->toBe($entry->payload_hash);
|
||||
|
||||
$export = $archive->export($entry, $admin, 'Anwalt');
|
||||
$temp = tempnam(sys_get_temp_dir(), 'pk-test-');
|
||||
file_put_contents($temp, $export);
|
||||
$outer = new ZipArchive;
|
||||
$outer->open($temp);
|
||||
expect($outer->getFromName('signature.txt'))->toContain('hmac-sha256=')
|
||||
->and(json_decode((string) $outer->getFromName('metadata.json'), true)['payload_sha256'])->toBe($entry->payload_hash)
|
||||
->and($outer->getFromName('payload.zip'))->toBe($zip);
|
||||
$outer->close();
|
||||
unlink($temp);
|
||||
|
||||
expect(ArchiveAccessLog::query()->count())->toBe(3)
|
||||
->and(ArchiveAccessLog::query()->where('action', 'open')->sole()->purpose)->toBe('Abmahnung prüfen');
|
||||
|
||||
expect(fn () => $archive->open($entry, $admin, ' '))->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
test('without a key nothing is archived and the business flow continues', function () {
|
||||
config()->set('archive.key', null);
|
||||
$pr = PressRelease::factory()->create();
|
||||
|
||||
$entry = app(EvidenceArchive::class)->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
|
||||
|
||||
expect($entry)->toBeNull()
|
||||
->and(ArchiveEntry::query()->count())->toBe(0)
|
||||
->and(app(EvidenceArchive::class)->isConfigured())->toBeFalse();
|
||||
});
|
||||
|
||||
test('entries are append-only except legal hold, retention and tombstone', function () {
|
||||
$admin = archiveAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
$entry = $archive->record(ArchiveEvent::LabelCorrected, $pr, $pr->id, ArchiveReasonCategory::Correction, 'admin', $admin->id, []);
|
||||
|
||||
expect(fn () => $entry->update(['event' => 'gdpr_change']))->toThrow(LogicException::class)
|
||||
->and(fn () => $entry->delete())->toThrow(LogicException::class);
|
||||
|
||||
// Der abgewiesene Versuch lässt das Modell schmutzig zurück — frisch laden.
|
||||
$entry = $entry->fresh();
|
||||
$archive->setLegalHold($entry, $admin, 'Abmahnung Az. 12/2026');
|
||||
expect($entry->fresh()->legal_hold)->toBeTrue()
|
||||
->and($entry->fresh()->legal_hold_set_by)->toBe($admin->id);
|
||||
|
||||
$this->travel(2)->years();
|
||||
$archive->releaseLegalHold($entry->fresh(), $admin, 'Verfahren beendet');
|
||||
$released = $entry->fresh();
|
||||
|
||||
expect($released->legal_hold)->toBeFalse()
|
||||
->and($released->retain_until->toDateString())->toBe(now()->endOfYear()->addYears(3)->toDateString());
|
||||
});
|
||||
|
||||
test('purge removes due payloads, keeps tombstones, respects legal hold, and reconcile undoes a restore', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = archiveAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
|
||||
$due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]);
|
||||
$held = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 2]);
|
||||
$archive->setLegalHold($held, $admin, 'Streit');
|
||||
$duePath = $due->payload_path;
|
||||
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
|
||||
$this->artisan('archive:purge', ['--dry-run' => true])->expectsOutputToContain('Fällig (Trockenlauf): 1')->assertSuccessful();
|
||||
expect(Storage::disk('archive')->exists($duePath))->toBeTrue();
|
||||
|
||||
$this->artisan('archive:purge')->expectsOutputToContain('Gelöscht: 1')->assertSuccessful();
|
||||
|
||||
expect($due->fresh()->isTombstoned())->toBeTrue()
|
||||
->and($due->fresh()->payload_path)->toBe($duePath)
|
||||
->and(Storage::disk('archive')->exists($duePath))->toBeFalse()
|
||||
->and($held->fresh()->isTombstoned())->toBeFalse()
|
||||
->and(fn () => $archive->open($due->fresh(), $admin, 'x'))->toThrow(RuntimeException::class);
|
||||
|
||||
$this->artisan('archive:export-tombstones')->expectsOutputToContain('1 Einträge')->assertSuccessful();
|
||||
$export = json_decode((string) Storage::disk('archive')->get(config('archive.tombstone_export_path')), true);
|
||||
expect($export['entries'][0]['id'])->toBe($due->id);
|
||||
|
||||
// „Restore": Sicherung bringt Zeile und Datei zurück.
|
||||
ArchiveEntry::withoutEvents(fn () => ArchiveEntry::query()->whereKey($due->id)->update(['deleted_at' => null]));
|
||||
Storage::disk('archive')->put($duePath, 'restored-cipher');
|
||||
|
||||
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Einträge erneut')->assertSuccessful();
|
||||
|
||||
expect($due->fresh()->isTombstoned())->toBeTrue()
|
||||
->and(Storage::disk('archive')->exists($duePath))->toBeFalse();
|
||||
});
|
||||
|
||||
test('removing a published image archives original, snapshot and confirmations and then drops the local evidence files', function () {
|
||||
['pr' => $pr, 'image' => $image] = archivePublishedWithImage();
|
||||
$admin = archiveAdmin();
|
||||
|
||||
app(PressReleaseImageRemover::class)->remove($image, $admin);
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', 'image_removed')->sole();
|
||||
expect($entry->subject_id)->toBe($image->id)
|
||||
->and($entry->press_release_id)->toBe($pr->id)
|
||||
->and($entry->actor_type)->toBe('admin')
|
||||
->and($entry->summary['files'])->toBe(3)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
|
||||
->and(Storage::disk('local')->exists($image->published_snapshot_path))->toBeFalse()
|
||||
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and(ImageRightsConfirmation::query()->count())->toBe(1);
|
||||
|
||||
$manifest = app(EvidenceArchive::class)->manifest($entry, $admin, 'Test');
|
||||
expect($manifest['image']['copyright'])->toBe('Anna Beispiel')
|
||||
->and($manifest['confirmations'])->toHaveCount(1)
|
||||
->and($manifest['credit_line'])->toBe('Foto: Anna Beispiel');
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$temp = tempnam(sys_get_temp_dir(), 'pk-test-');
|
||||
file_put_contents($temp, app(EvidenceArchive::class)->open($entry, $admin, 'Test'));
|
||||
$zip->open($temp);
|
||||
expect($zip->getFromName('original.jpg'))->toBe('original-bytes')
|
||||
->and($zip->getFromName('published.jpg'))->toBe('snapshot-bytes')
|
||||
->and($zip->getFromName('published.json'))->toContain('Foto: Anna Beispiel');
|
||||
$zip->close();
|
||||
unlink($temp);
|
||||
});
|
||||
|
||||
test('depublication, admin removal and admin status change archive the last published state', function () {
|
||||
['pr' => $pr] = archivePublishedWithImage();
|
||||
$this->actingAs(archiveAdmin());
|
||||
|
||||
app(PressReleaseService::class)->depublish($pr);
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
|
||||
expect($entry->reason_category)->toBe(ArchiveReasonCategory::CustomerRequest)
|
||||
->and($entry->summary['images'])->toBe(1)
|
||||
->and($entry->summary['files'])->toBe(1);
|
||||
|
||||
$manifest = app(EvidenceArchive::class)->manifest($entry, auth()->user(), 'Test');
|
||||
expect($manifest['press_release']['title'])->toBe($pr->title)
|
||||
->and($manifest['press_release']['company']['name'])->toBe('Beispiel AG')
|
||||
->and($manifest['images'][0]['credit_line'])->toBe('Foto: Anna Beispiel');
|
||||
|
||||
['pr' => $second] = archivePublishedWithImage();
|
||||
$originalTitle = $second->title;
|
||||
app(PressReleaseService::class)->deleteFromAdmin($second);
|
||||
|
||||
$removed = ArchiveEntry::query()->where('press_release_id', $second->id)->sole();
|
||||
expect(app(EvidenceArchive::class)->manifest($removed, auth()->user(), 'Test')['press_release']['title'])->toBe($originalTitle)
|
||||
->and($second->fresh()->text)->toContain('entfernt');
|
||||
|
||||
['pr' => $third] = archivePublishedWithImage();
|
||||
app(PressReleaseService::class)->changeStatusFromAdmin($third, PressReleaseStatus::Rejected, 'Nachträglich zurückgezogen');
|
||||
|
||||
expect(ArchiveEntry::query()->where('press_release_id', $third->id)->sole()->reason_category)->toBe(ArchiveReasonCategory::OwnDecision);
|
||||
});
|
||||
|
||||
test('an anonymisation archives the before and after values with the request reference', function () {
|
||||
['pr' => $pr] = archivePublishedWithImage();
|
||||
$pr->forceFill(['text' => '<p>Max Mustermann war dabei.</p>'])->save();
|
||||
|
||||
app(AnonymizationService::class)->execute($pr, ['Max Mustermann']);
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', 'gdpr_change')->sole();
|
||||
$manifest = app(EvidenceArchive::class)->manifest($entry, archiveAdmin(), 'Auskunft Art. 15');
|
||||
|
||||
expect($manifest['before']['text'])->toContain('Max Mustermann')
|
||||
->and($manifest['after']['text'])->not->toContain('Max Mustermann')
|
||||
->and($entry->reason_category)->toBe(ArchiveReasonCategory::Gdpr);
|
||||
});
|
||||
|
||||
test('a label correction is mirrored as archive entry', function () {
|
||||
['image' => $image] = archivePublishedWithImage();
|
||||
$admin = archiveAdmin();
|
||||
|
||||
app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer Nachweis', 'Einwand des Fotografen');
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', 'label_corrected')->sole();
|
||||
expect($entry->summary['field'])->toBe('copyright')
|
||||
->and(app(EvidenceArchive::class)->manifest($entry, $admin, 'Test')['correction']['new_value'])->toBe('Neuer Nachweis');
|
||||
});
|
||||
|
||||
test('the admin archive page lists entries and downloads only with a purpose', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = archiveAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
$entry = app(EvidenceArchive::class)->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'admin', $admin->id, ['t' => 1]);
|
||||
|
||||
$this->actingAs($admin)->get('https://pressekonto.test/admin/archive')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Meldung depubliziert')
|
||||
->assertSeeText('aufbewahrt');
|
||||
|
||||
$this->actingAs($admin)->get('https://pressekonto.test/admin/archive/'.$entry->id.'/download')
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Zweck des Zugriffs');
|
||||
|
||||
expect(ArchiveAccessLog::query()->count())->toBe(0);
|
||||
|
||||
$this->actingAs($admin)->get('https://pressekonto.test/admin/archive/'.$entry->id.'/download?purpose=Auskunft')
|
||||
->assertSuccessful()
|
||||
->assertHeader('Content-Type', 'application/zip');
|
||||
|
||||
expect(ArchiveAccessLog::query()->sole()->purpose)->toBe('Auskunft');
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
$this->actingAs($customer)->get('https://pressekonto.test/admin/archive')->assertForbidden();
|
||||
});
|
||||
|
|
@ -286,6 +286,7 @@ test('the evidence original path never leaks outside the allowed files', functio
|
|||
'app/Services/PressRelease/Images/PressReleaseImageUploader.php',
|
||||
'app/Services/PressRelease/Images/PressReleaseImageRemover.php',
|
||||
'app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php',
|
||||
'app/Services/Archive/PressReleaseArchiver.php',
|
||||
];
|
||||
|
||||
$offenders = collect(array_merge(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue