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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,393 @@
<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\User;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
/**
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4).
*
* Jeder Eintrag ist ein verschlüsseltes ZIP (manifest.json plus Dateien)
* auf der Archiv-Disk, eigener Schlüssel (ARCHIVE_KEY), SHA-256 des
* Klartext-ZIPs am Eintrag. Lesen nur über open()/export() mit Zweck und
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
* anhand des täglich exportierten Tombstone-Stands.
*/
class EvidenceArchive
{
public function isConfigured(): bool
{
return $this->keyBytes() !== null;
}
/**
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
* @param array<string, string> $files Dateiname → Bytes
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
*/
public function record(
ArchiveEvent $event,
Model $subject,
?int $pressReleaseId,
ArchiveReasonCategory $reason,
string $actorType,
?int $actorId,
array $manifest,
array $files = [],
array $summary = [],
): ?ArchiveEntry {
if (! $this->isConfigured()) {
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
'event' => $event->value,
'subject' => $subject->getMorphClass().'#'.$subject->getKey(),
]);
return null;
}
$manifest = [
'event' => $event->value,
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'reason_category' => $reason->value,
'actor_type' => $actorType,
'actor_id' => $actorId,
'recorded_at' => now()->toIso8601String(),
] + $manifest;
$zip = $this->buildZip($manifest, $files);
$hash = hash('sha256', $zip);
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
if (! $this->disk()->put($path, $this->encrypter()->encrypt($zip, false))) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
}
return ArchiveEntry::query()->create([
'subject_type' => $subject->getMorphClass(),
'subject_id' => $subject->getKey(),
'press_release_id' => $pressReleaseId,
'event' => $event,
'actor_type' => $actorType,
'actor_id' => $actorId,
'reason_category' => $reason,
'summary' => $summary ?: null,
'payload_path' => $path,
'payload_hash' => $hash,
'payload_bytes' => strlen($zip),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
'created_at' => now(),
]);
}
/**
* Entschlüsseltes ZIP eines Eintrags — protokolliert mit Zweck.
*/
public function open(ArchiveEntry $entry, User $admin, string $purpose): string
{
$this->logAccess($entry, $admin, 'open', $purpose);
return $this->plaintext($entry);
}
/**
* manifest.json eines Eintrags als Array — protokolliert mit Zweck.
*
* @return array<string, mixed>
*/
public function manifest(ArchiveEntry $entry, User $admin, string $purpose): array
{
$this->logAccess($entry, $admin, 'manifest', $purpose);
$temp = $this->tempFile($this->plaintext($entry));
try {
$zip = new ZipArchive;
if ($zip->open($temp) !== true) {
throw new RuntimeException('Beweisarchiv: Payload ist kein lesbares ZIP.');
}
$json = $zip->getFromName('manifest.json');
$zip->close();
} finally {
@unlink($temp);
}
return is_string($json) ? (array) json_decode($json, true) : [];
}
/**
* Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und
* einer HMAC-Signatur über den Hash (mit dem Archivschlüssel).
*/
public function export(ArchiveEntry $entry, User $admin, string $purpose): string
{
$this->logAccess($entry, $admin, 'export', $purpose);
$payload = $this->plaintext($entry);
return $this->buildZip([
'entry_id' => $entry->getKey(),
'event' => $entry->event->value,
'subject' => $entry->subject_type.'#'.$entry->subject_id,
'press_release_id' => $entry->press_release_id,
'reason_category' => $entry->reason_category->value,
'created_at' => $entry->created_at?->toIso8601String(),
'retain_until' => $entry->retain_until->toDateString(),
'legal_hold' => $entry->legal_hold,
'payload_sha256' => $entry->payload_hash,
'exported_at' => now()->toIso8601String(),
'exported_by' => $admin->getKey(),
], [
'payload.zip' => $payload,
'signature.txt' => 'sha256='.hash('sha256', $payload)."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, (string) $this->keyBytes())."\n",
], 'metadata.json');
}
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
$entry->forceFill([
'legal_hold' => true,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
])->save();
}
/**
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
*/
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
{
$entry->forceFill([
'legal_hold' => false,
'legal_hold_note' => Str::limit($note, 990, ''),
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
])->save();
}
/**
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
*/
public function purgeDue(bool $dryRun = false): int
{
$count = 0;
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$count, $dryRun): void {
foreach ($entries as $entry) {
$count++;
if ($dryRun) {
continue;
}
$this->tombstone($entry);
}
});
return $count;
}
/**
* Tombstone-Stand exportieren, damit er einen Restore überlebt.
*/
public function exportTombstones(): int
{
$rows = ArchiveEntry::query()->whereNotNull('deleted_at')->orderBy('id')
->get(['id', 'payload_path', 'deleted_at'])
->map(fn (ArchiveEntry $entry): array => [
'id' => $entry->getKey(),
'payload_path' => $entry->payload_path,
'deleted_at' => $entry->deleted_at?->toIso8601String(),
])
->values()
->all();
$this->disk()->put((string) config('archive.tombstone_export_path'), (string) json_encode([
'exported_at' => now()->toIso8601String(),
'entries' => $rows,
], JSON_PRETTY_PRINT));
return count($rows);
}
/**
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
*
* @return array{re_tombstoned: int, files_removed: int}
*/
public function reconcileAfterRestore(): array
{
$result = ['re_tombstoned' => 0, 'files_removed' => 0];
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
if (! is_string($raw) || $raw === '') {
return $result;
}
$rows = (array) (json_decode($raw, true)['entries'] ?? []);
foreach ($rows as $row) {
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
if ($path !== null && $this->disk()->exists($path)) {
$this->disk()->delete($path);
$result['files_removed']++;
}
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
if ($entry !== null && ! $entry->isTombstoned()) {
$entry->forceFill(['deleted_at' => now()])->save();
$result['re_tombstoned']++;
}
}
return $result;
}
private function tombstone(ArchiveEntry $entry): void
{
DB::transaction(function () use ($entry): void {
if ($entry->payload_path !== null && $this->disk()->exists($entry->payload_path)) {
$this->disk()->delete($entry->payload_path);
}
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
// wiederfindet; der Inhalt ist weg.
$entry->forceFill(['deleted_at' => now()])->save();
});
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
}
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
{
$purpose = trim($purpose);
if ($purpose === '') {
throw new RuntimeException('Beweisarchiv: Zugriff ohne Zweck ist nicht vorgesehen.');
}
ArchiveAccessLog::query()->create([
'entry_id' => $entry->getKey(),
'admin_id' => $admin->getKey(),
'action' => $action,
'purpose' => Str::limit($purpose, 490, ''),
'accessed_at' => now(),
]);
}
private function plaintext(ArchiveEntry $entry): string
{
if ($entry->isTombstoned() || $entry->payload_path === null) {
throw new RuntimeException('Beweisarchiv: Eintrag ist nach Fristablauf gelöscht (Tombstone).');
}
$cipher = $this->disk()->get($entry->payload_path);
if (! is_string($cipher) || $cipher === '') {
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
}
$zip = (string) $this->encrypter()->decrypt($cipher, false);
if (! hash_equals((string) $entry->payload_hash, hash('sha256', $zip))) {
throw new RuntimeException('Beweisarchiv: Hash des Payloads stimmt nicht mit dem Eintrag überein.');
}
return $zip;
}
/**
* @param array<string, mixed> $manifest
* @param array<string, string> $files
*/
private function buildZip(array $manifest, array $files, string $manifestName = 'manifest.json'): string
{
$temp = $this->tempFile('');
$zip = new ZipArchive;
try {
if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Beweisarchiv: ZIP konnte nicht erzeugt werden.');
}
$zip->addFromString($manifestName, (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
foreach ($files as $name => $bytes) {
$zip->addFromString($name, $bytes);
}
$zip->close();
return (string) file_get_contents($temp);
} finally {
@unlink($temp);
}
}
private function tempFile(string $contents): string
{
$temp = tempnam(sys_get_temp_dir(), 'pk-archive-');
if ($temp === false) {
throw new RuntimeException('Beweisarchiv: keine temporäre Datei verfügbar.');
}
file_put_contents($temp, $contents);
return $temp;
}
private function encrypter(): Encrypter
{
$key = $this->keyBytes();
if ($key === null) {
throw new RuntimeException('Beweisarchiv: ARCHIVE_KEY fehlt.');
}
return new Encrypter($key, (string) config('archive.cipher', 'AES-256-CBC'));
}
private function keyBytes(): ?string
{
$key = (string) config('archive.key');
if ($key === '') {
return null;
}
if (Str::startsWith($key, 'base64:')) {
$key = base64_decode(substr($key, 7), true) ?: '';
}
return strlen($key) === 32 ? $key : null;
}
private function disk(): Filesystem
{
return Storage::disk((string) config('archive.disk', 'archive'));
}
}

View file

@ -0,0 +1,214 @@
<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveEntry;
use App\Models\ImageDeclarationCorrection;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Support\ImageCredit;
use Illuminate\Support\Facades\Storage;
/**
* Baut die Archiv-Payloads für die Ereignisse aus Decision-Update 4.1 und
* ruft das Beweisarchiv. Fachdaten liegen im Manifest, Dateien (Original,
* veröffentlichte Fassung) im ZIP. Fehlt der Schlüssel, protokolliert das
* Archiv den Ausfall; die Geschäftsvorgänge laufen trotzdem weiter.
*/
class PressReleaseArchiver
{
public function __construct(private readonly EvidenceArchive $archive) {}
/**
* Veröffentlichtes Bild entfernt oder ersetzt: Original, veröffentlichte
* Fassung, Bestätigungen und Korrekturen wandern ins Archiv.
*/
public function imageRemoved(PressReleaseImage $image, ?User $actor = null, ArchiveEvent $event = ArchiveEvent::ImageRemoved, ArchiveReasonCategory $reason = ArchiveReasonCategory::CustomerRequest): ?ArchiveEntry
{
$files = [];
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
if ($image->original_path !== null && $disk->exists($image->original_path)) {
$files['original.'.pathinfo($image->original_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->original_path);
}
if ($image->published_snapshot_path !== null && $disk->exists($image->published_snapshot_path)) {
$files['published.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION)] = (string) $disk->get($image->published_snapshot_path);
$sidecar = preg_replace('/\.[a-z0-9]+$/i', '.json', $image->published_snapshot_path);
if (is_string($sidecar) && $disk->exists($sidecar)) {
$files['published.json'] = (string) $disk->get($sidecar);
}
}
$pressRelease = $image->pressRelease;
return $this->archive->record(
$event,
$image,
$image->press_release_id,
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'image' => $image->only([
'id', 'press_release_id', 'path', 'variants', 'title', 'description', 'copyright', 'credit_source', 'author',
'license_url', 'source_url', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'ai_declaration_locked',
'provenance_status', 'provenance', 'original_sha256', 'published_snapshot_sha256', 'rules_version',
'published_ever_at', 'withheld_at', 'created_at',
]),
'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null,
'confirmations' => $image->rightsConfirmations()->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
'corrections' => ImageDeclarationCorrection::query()->where('press_release_image_id', $image->getKey())->orderBy('id')->get()->map(fn ($c) => $c->toArray())->all(),
'press_release' => $pressRelease ? $this->pressReleaseHeader($pressRelease) : null,
],
$files,
['image_id' => $image->getKey(), 'files' => count($files)],
);
}
/**
* Meldung aus dem öffentlichen Angebot genommen: letzter veröffentlichter
* Stand mit Text, Bildern (Snapshots), Kontaktfeldern, Einreicher, Zeiten.
*/
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null): ?ArchiveEntry
{
$pressRelease->loadMissing(['company', 'user', 'contacts', 'images']);
$files = [];
$images = [];
foreach ($pressRelease->images as $image) {
$entry = [
'id' => $image->getKey(),
'credit_line' => ImageCredit::for($image, $pressRelease->company)->line(),
'ai_declaration' => $image->ai_declaration?->value,
'ai_declaration_source' => $image->ai_declaration_source?->value,
'original_sha256' => $image->original_sha256,
'published_snapshot_sha256' => $image->published_snapshot_sha256,
'rules_version' => $image->rules_version,
];
if ($image->published_snapshot_path !== null) {
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
if ($disk->exists($image->published_snapshot_path)) {
$name = 'image-'.$image->getKey().'.'.pathinfo($image->published_snapshot_path, PATHINFO_EXTENSION);
$files[$name] = (string) $disk->get($image->published_snapshot_path);
$entry['file'] = $name;
}
}
$images[] = $entry;
}
return $this->archive->record(
ArchiveEvent::PressReleaseDepublished,
$pressRelease,
$pressRelease->getKey(),
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'press_release' => $this->pressReleaseHeader($pressRelease) + $pressRelease->only([
'subtitle', 'text', 'keywords', 'backlink_url', 'language', 'category_id', 'boilerplate_override',
]),
'contacts' => $pressRelease->contacts->map(fn ($contact) => $contact->only(['id', 'title', 'first_name', 'last_name', 'email', 'phone', 'responsibility']))->all(),
'images' => $images,
'note' => $note,
],
$files,
['images' => count($images), 'files' => count($files)],
);
}
/**
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
* den Antrag.
*
* @param array<string, mixed> $before
* @param array<string, mixed> $after
*/
public function gdprChange(PressRelease $pressRelease, array $before, array $after, ?LegalRequest $legalRequest, ?User $actor = null, ArchiveReasonCategory $reason = ArchiveReasonCategory::Gdpr, ?string $note = null): ?ArchiveEntry
{
return $this->archive->record(
ArchiveEvent::GdprChange,
$pressRelease,
$pressRelease->getKey(),
$reason,
$this->actorType($actor),
$actor?->getKey(),
[
'press_release' => $this->pressReleaseHeader($pressRelease),
'before' => $before,
'after' => $after,
'legal_request' => $legalRequest ? [
'id' => $legalRequest->getKey(),
'reference' => $legalRequest->reference(),
'type' => $legalRequest->type->value,
'requester_email' => $legalRequest->requester_email,
] : null,
'note' => $note,
],
[],
['fields' => count(array_keys($after)), 'legal_request_id' => $legalRequest?->getKey()],
);
}
/**
* Korrekturweg (E.6): alter und neuer Wert, Quelle, Grund, Bearbeiter.
*/
public function labelCorrected(PressReleaseImage $image, ImageDeclarationCorrection $correction, ?User $actor = null): ?ArchiveEntry
{
return $this->archive->record(
ArchiveEvent::LabelCorrected,
$image,
$image->press_release_id,
ArchiveReasonCategory::Correction,
$this->actorType($actor),
$actor?->getKey(),
[
'correction' => $correction->toArray(),
'image' => $image->only(['id', 'copyright', 'ai_declaration', 'ai_declaration_user', 'ai_declaration_source', 'withheld_at']),
],
[],
['correction_id' => $correction->getKey(), 'field' => $correction->field],
);
}
/**
* @return array<string, mixed>
*/
private function pressReleaseHeader(PressRelease $pressRelease): array
{
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), 'user']);
return [
'id' => $pressRelease->getKey(),
'uuid' => $pressRelease->uuid,
'portal' => $pressRelease->portal->value,
'title' => $pressRelease->title,
'slug' => $pressRelease->slug,
'status' => $pressRelease->status->value,
'published_at' => $pressRelease->published_at?->toIso8601String(),
'published_ever_at' => $pressRelease->published_ever_at?->toIso8601String(),
'depublished_at' => $pressRelease->depublished_at?->toIso8601String(),
'company' => $pressRelease->company ? ['id' => $pressRelease->company->getKey(), 'name' => $pressRelease->company->name] : null,
'submitter' => $pressRelease->user ? ['id' => $pressRelease->user->getKey(), 'email' => $pressRelease->user->email, 'name' => $pressRelease->user->name] : null,
];
}
private function actorType(?User $actor): string
{
if ($actor === null) {
return 'system';
}
return $actor->canAccessAdmin() ? 'admin' : 'customer';
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

31
config/archive.php Normal file
View 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',
];

View file

@ -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'),

View file

@ -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');
}
};

View file

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

View 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>

View file

@ -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.*')"

View 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>

View file

@ -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');

View file

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

View 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();
});

View file

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