diff --git a/.env.example b/.env.example index f7d815f9..19d853e5 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Console/Commands/ArchiveExportTombstones.php b/app/Console/Commands/ArchiveExportTombstones.php new file mode 100644 index 00000000..c93ac49f --- /dev/null +++ b/app/Console/Commands/ArchiveExportTombstones.php @@ -0,0 +1,25 @@ +exportTombstones(); + $this->info("Tombstone-Export geschrieben: {$count} Einträge."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ArchiveKey.php b/app/Console/Commands/ArchiveKey.php new file mode 100644 index 00000000..c4633ccc --- /dev/null +++ b/app/Console/Commands/ArchiveKey.php @@ -0,0 +1,24 @@ +line('ARCHIVE_KEY=base64:'.base64_encode(random_bytes(32))); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ArchivePurge.php b/app/Console/Commands/ArchivePurge.php new file mode 100644 index 00000000..4d188d65 --- /dev/null +++ b/app/Console/Commands/ArchivePurge.php @@ -0,0 +1,26 @@ +purgeDue((bool) $this->option('dry-run')); + + $this->info(($this->option('dry-run') ? 'Fällig (Trockenlauf): ' : 'Gelöscht: ').$count.' Archiveinträge.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ArchiveReconcileAfterRestore.php b/app/Console/Commands/ArchiveReconcileAfterRestore.php new file mode 100644 index 00000000..9bc62e8d --- /dev/null +++ b/app/Console/Commands/ArchiveReconcileAfterRestore.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/app/Console/Commands/BackfillPublishedEver.php b/app/Console/Commands/BackfillPublishedEver.php index aaaa0486..bd520244 100644 --- a/app/Console/Commands/BackfillPublishedEver.php +++ b/app/Console/Commands/BackfillPublishedEver.php @@ -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') diff --git a/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php b/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php index b8ede2c8..e693b527 100644 --- a/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php +++ b/app/Console/Commands/PurgeInactiveLegacyPressReleaseImages.php @@ -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') diff --git a/app/Enums/ArchiveEvent.php b/app/Enums/ArchiveEvent.php new file mode 100644 index 00000000..d761bb23 --- /dev/null +++ b/app/Enums/ArchiveEvent.php @@ -0,0 +1,26 @@ + 'Bild entfernt', + self::ImageReplaced => 'Bild ersetzt', + self::PressReleaseDepublished => 'Meldung depubliziert', + self::GdprChange => 'DSGVO-Änderung', + self::LabelCorrected => 'Kennzeichnung korrigiert', + }; + } +} diff --git a/app/Enums/ArchiveReasonCategory.php b/app/Enums/ArchiveReasonCategory.php new file mode 100644 index 00000000..04775067 --- /dev/null +++ b/app/Enums/ArchiveReasonCategory.php @@ -0,0 +1,29 @@ + 'Antrag des Kunden', + self::ReportProcedure => 'Meldeverfahren', + self::Gdpr => 'DSGVO', + self::TermEnd => 'Laufzeitende', + self::OwnDecision => 'Eigene Entscheidung', + self::Correction => 'Korrektur', + }; + } +} diff --git a/app/Http/Controllers/Admin/ArchiveEntryController.php b/app/Http/Controllers/Admin/ArchiveEntryController.php new file mode 100644 index 00000000..c51e2385 --- /dev/null +++ b/app/Http/Controllers/Admin/ArchiveEntryController.php @@ -0,0 +1,54 @@ +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', + ]); + } +} diff --git a/app/Models/ArchiveAccessLog.php b/app/Models/ArchiveAccessLog.php new file mode 100644 index 00000000..ef63ddbf --- /dev/null +++ b/app/Models/ArchiveAccessLog.php @@ -0,0 +1,46 @@ + '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 + */ + public function entry(): BelongsTo + { + return $this->belongsTo(ArchiveEntry::class, 'entry_id'); + } + + /** + * @return BelongsTo + */ + public function admin(): BelongsTo + { + return $this->belongsTo(User::class, 'admin_id'); + } +} diff --git a/app/Models/ArchiveEntry.php b/app/Models/ArchiveEntry.php new file mode 100644 index 00000000..83de2efc --- /dev/null +++ b/app/Models/ArchiveEntry.php @@ -0,0 +1,112 @@ + */ + 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 $query + * @return Builder + */ + public function scopeDueForPurge(Builder $query): Builder + { + return $query->whereNull('deleted_at')->where('legal_hold', false)->whereDate('retain_until', '<', today()); + } + + /** + * @return MorphTo + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return HasMany + */ + public function accessLog(): HasMany + { + return $this->hasMany(ArchiveAccessLog::class, 'entry_id'); + } +} diff --git a/app/Services/Archive/EvidenceArchive.php b/app/Services/Archive/EvidenceArchive.php new file mode 100644 index 00000000..14fec571 --- /dev/null +++ b/app/Services/Archive/EvidenceArchive.php @@ -0,0 +1,393 @@ +keyBytes() !== null; + } + + /** + * @param array $manifest Fachdaten (werden als manifest.json abgelegt) + * @param array $files Dateiname → Bytes + * @param array $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 + */ + 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 $manifest + * @param array $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')); + } +} diff --git a/app/Services/Archive/PressReleaseArchiver.php b/app/Services/Archive/PressReleaseArchiver.php new file mode 100644 index 00000000..8347a868 --- /dev/null +++ b/app/Services/Archive/PressReleaseArchiver.php @@ -0,0 +1,214 @@ +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 $before + * @param array $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 + */ + 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'; + } +} diff --git a/app/Services/PressRelease/AnonymizationService.php b/app/Services/PressRelease/AnonymizationService.php index e762d3ff..b8e23ffa 100644 --- a/app/Services/PressRelease/AnonymizationService.php +++ b/app/Services/PressRelease/AnonymizationService.php @@ -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( diff --git a/app/Services/PressRelease/CorrectionService.php b/app/Services/PressRelease/CorrectionService.php index 329e6d4b..4b8749c6 100644 --- a/app/Services/PressRelease/CorrectionService.php +++ b/app/Services/PressRelease/CorrectionService.php @@ -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() diff --git a/app/Services/PressRelease/Images/ImageCorrectionService.php b/app/Services/PressRelease/Images/ImageCorrectionService.php index 8a74af61..db40cba1 100644 --- a/app/Services/PressRelease/Images/ImageCorrectionService.php +++ b/app/Services/PressRelease/Images/ImageCorrectionService.php @@ -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; } } diff --git a/app/Services/PressRelease/Images/PressReleaseImageRemover.php b/app/Services/PressRelease/Images/PressReleaseImageRemover.php index 47a08b27..b6d05406 100644 --- a/app/Services/PressRelease/Images/PressReleaseImageRemover.php +++ b/app/Services/PressRelease/Images/PressReleaseImageRemover.php @@ -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); + } + } + } } diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index a98a786c..dc26f24d 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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 diff --git a/config/archive.php b/config/archive.php new file mode 100644 index 00000000..927f5d14 --- /dev/null +++ b/config/archive.php @@ -0,0 +1,31 @@ + 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', + +]; diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd9..57081161 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -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'), diff --git a/database/migrations/2026_09_22_120000_create_archive_entries_table.php b/database/migrations/2026_09_22_120000_create_archive_entries_table.php new file mode 100644 index 00000000..29a62bcf --- /dev/null +++ b/database/migrations/2026_09_22_120000_create_archive_entries_table.php @@ -0,0 +1,66 @@ +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'); + } +}; diff --git a/dev/betrieb/deploy-runbook.md b/dev/betrieb/deploy-runbook.md index 8210bfc2..6edb67bd 100644 --- a/dev/betrieb/deploy-runbook.md +++ b/dev/betrieb/deploy-runbook.md @@ -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) diff --git a/resources/views/admin/archive/purpose.blade.php b/resources/views/admin/archive/purpose.blade.php new file mode 100644 index 00000000..528b5cc9 --- /dev/null +++ b/resources/views/admin/archive/purpose.blade.php @@ -0,0 +1,13 @@ + +
+ {{ __('Zweck des Zugriffs') }} + {{ __('Archiveintrag #:id (:event). Jeder Zugriff auf das Beweisarchiv wird mit Zweck protokolliert.', ['id' => $entry->id, 'event' => $entry->event->label()]) }} +
+ +
+ {{ $action === 'export' ? __('Export herunterladen') : __('Payload öffnen') }} + {{ __('Abbrechen') }} +
+ +
+
diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 02d05ef7..f1e7fb1a 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -142,6 +142,10 @@ :current="request()->routeIs('admin.reports.activity')" wire:navigate> {{ __('Verlauf') }} + + {{ __('Beweisarchiv') }} + {{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}} 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, + ]; + } +}; ?> + +
+
+
+ {{ __('Beweisarchiv') }} + {{ __('Verschlüsselte Datensätze zu entfernten Veröffentlichungen. Drei Jahre zum Jahresende, Legal Hold hat Vorrang. Jeder Zugriff wird mit Zweck protokolliert.') }} +
+
+ + {{ __('Alle Ereignisse') }} + @foreach ($events as $eventOption) + {{ $eventOption->label() }} + @endforeach + + +
+
+ + @unless ($configured) + + {{ __('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.') }} + + @endunless + + + + + + + + + + + + + + + + + @forelse ($entries as $entry) + + + + + + + + + + + @empty + + @endforelse + +
#{{ __('Ereignis') }}{{ __('Betrifft') }}{{ __('Grund') }}{{ __('Angelegt') }}{{ __('Frist bis') }}{{ __('Status') }}
{{ $entry->id }}{{ $entry->event->label() }} + {{ class_basename($entry->subject_type) }} #{{ $entry->subject_id }} + @if ($entry->press_release_id) + · PM #{{ $entry->press_release_id }} + @endif + {{ $entry->reason_category->label() }}{{ $entry->created_at?->format('d.m.Y H:i') }}{{ $entry->retain_until->format('d.m.Y') }} + @if ($entry->isTombstoned()) + {{ __('gelöscht') }} + @elseif ($entry->legal_hold) + Legal Hold + @else + {{ __('aufbewahrt') }} + @endif + + @unless ($entry->isTombstoned()) + + {{ $entry->legal_hold ? __('Hold aufheben') : __('Legal Hold') }} + + {{ __('Öffnen') }} + {{ __('Export') }} + @endunless +
{{ __('Noch keine Archiveinträge.') }}
+
{{ $entries->links() }}
+
+ + @if ($holdEntry) + + {{ $holdEntry->legal_hold ? __('Legal Hold aufheben (Eintrag #:id)', ['id' => $holdEntry->id]) : __('Legal Hold setzen (Eintrag #:id)', ['id' => $holdEntry->id]) }} + @if ($holdEntry->legal_hold_note) + {{ __('Letzter Vermerk: :note', ['note' => $holdEntry->legal_hold_note]) }} + @endif +
+ +
+ {{ $holdEntry->legal_hold ? __('Aufheben') : __('Setzen') }} + {{ __('Abbrechen') }} +
+
+
+ @endif +
diff --git a/routes/admin.php b/routes/admin.php index 64224f30..efeec522 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,5 +1,6 @@ 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'); diff --git a/routes/console.php b/routes/console.php index 536d931b..385ac440 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,8 @@ 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() diff --git a/tests/Feature/Archive/EvidenceArchiveTest.php b/tests/Feature/Archive/EvidenceArchiveTest.php new file mode 100644 index 00000000..931154df --- /dev/null +++ b/tests/Feature/Archive/EvidenceArchiveTest.php @@ -0,0 +1,314 @@ +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' => '

Max Mustermann war dabei.

'])->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(); +}); diff --git a/tests/Feature/Images/ImageLifecycleTest.php b/tests/Feature/Images/ImageLifecycleTest.php index 45c5a8f4..d0c5cc05 100644 --- a/tests/Feature/Images/ImageLifecycleTest.php +++ b/tests/Feature/Images/ImageLifecycleTest.php @@ -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(