*/ public const REDACTABLE_FIELDS = ['title', 'subtitle', 'text', 'boilerplate_override', 'keywords']; public const DEFAULT_REPLACEMENT = '[entfernt]'; public function __construct( private readonly ActivityLog $activityLog, private readonly PressReleaseImageRemover $imageRemover, private readonly PressReleaseArchiver $archiver, private readonly ImageService $imageService, ) {} /** * Pflicht-Vorschau: Was würde ersetzt? Verändert die PM nicht. * * @param list $terms * @return array{fields: array, total_hits: int} */ public function preview(PressRelease $pressRelease, array $terms, string $replacement = self::DEFAULT_REPLACEMENT): array { $fields = []; $totalHits = 0; foreach (self::REDACTABLE_FIELDS as $field) { $value = (string) ($pressRelease->getAttribute($field) ?? ''); if ($value === '') { continue; } [$after, $hits] = $this->redact($value, $terms, $replacement); if ($hits > 0) { $fields[$field] = ['hits' => $hits, 'after' => $after]; $totalHits += $hits; } } return ['fields' => $fields, 'total_hits' => $totalHits]; } /** * Führt die Schwärzung aus: Snapshot → Text-Replace → Kontakte lösen → * Anhänge entfernen → Re-Sync-Sperre → Commit → Bilder über den * Remover (Beweisarchiv, N12) → Archiveintrag → Cache-Invalidierung → * Verlaufs-Eintrag ohne Klartext-Altwerte. * * @param list $terms * @param list $removeImageIds * @param list $removeAttachmentIds */ public function execute( PressRelease $pressRelease, array $terms, string $replacement = self::DEFAULT_REPLACEMENT, bool $detachContacts = true, array $removeImageIds = [], array $removeAttachmentIds = [], ?LegalRequest $legalRequest = null, ): PressReleaseRedactionSnapshot { $totalHits = 0; $images = collect(); $snapshot = DB::transaction(function () use ( $pressRelease, $terms, $replacement, $detachContacts, $removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, &$images, ): PressReleaseRedactionSnapshot { $contactIds = $detachContacts ? $pressRelease->contacts()->pluck('contacts.id')->all() : []; $images = $pressRelease->images()->whereIn('id', $removeImageIds)->get(); $attachments = $pressRelease->attachments()->whereIn('id', $removeAttachmentIds)->get(); $snapshot = PressReleaseRedactionSnapshot::capture($pressRelease, [ 'fields' => $pressRelease->only(self::REDACTABLE_FIELDS), 'contact_ids' => $contactIds, 'images' => $images->map(fn ($image) => $image->only(['id', 'disk', 'path', 'title']))->all(), 'attachments' => $attachments->map(fn ($attachment) => $attachment->only(['id', 'disk', 'path']))->all(), 'terms' => $terms, 'replacement' => $replacement, ], $legalRequest); foreach (self::REDACTABLE_FIELDS as $field) { $value = (string) ($pressRelease->getAttribute($field) ?? ''); if ($value === '') { continue; } [$after, $hits] = $this->redact($value, $terms, $replacement); if ($hits > 0) { $pressRelease->setAttribute($field, $after); $totalHits += $hits; } } // Marker „Archivierung steht aus" in derselben Transaktion (N11). $pressRelease->setAttribute('archive_pending_at', now()); $pressRelease->save(); if ($contactIds !== []) { $pressRelease->contacts()->detach($contactIds); } foreach ($attachments as $attachment) { $this->deletePublicFiles($attachment->disk, array_filter([$attachment->path])); $attachment->delete(); } $pressRelease->lockSync( 'Anonymisierung im Meldeverfahren'.($legalRequest ? ' '.$legalRequest->reference() : '') ); return $snapshot; }); // Klarname in Pressebild/PDF: über den gemeinsamen Lebenszyklus // (Beweisarchiv bei veröffentlichten Bildern, Sofortlöschung sonst). $archiveFailed = false; foreach ($images as $image) { $archiveFailed = ! $this->removeImage($image, $legalRequest) || $archiveFailed; } $this->forgetSitemapCaches(); // Beweisarchiv (Block F.4): Diff alt/neu mit Antrag — dort dürfen die // Altwerte liegen (verschlüsselt, zugriffsprotokolliert), im // Aktivitätsverlauf nicht. Vorher-Stand aus dem Snapshot (N11). $entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $terms, $removeImageIds); if ($entry !== null && ! $archiveFailed) { $pressRelease->forceFill(['archive_pending_at' => null])->save(); } // Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die // personenbezogenen Daten im Verlauf weiter. $this->activityLog->record( ActivityType::Anonymization, sprintf( 'PM anonymisiert%s: %d Ersetzung(en), %d Kontakt(e) gelöst, %d Bild(er), %d Anhang/Anhänge entfernt.', $legalRequest ? ' ('.$legalRequest->reference().')' : '', $totalHits, count($snapshot->encrypted_before['contact_ids'] ?? []), count($snapshot->encrypted_before['images'] ?? []), count($snapshot->encrypted_before['attachments'] ?? []), ), user: Auth::user(), subject: $pressRelease, meta: ['legal_request_id' => $legalRequest?->getKey(), 'snapshot_id' => $snapshot->getKey(), 'archive_entry_id' => $entry?->getKey()], ); return $snapshot; } /** * Bild über den Remover entfernen (N12). Scheitert das Archiv, muss der * Klarname trotzdem sofort aus der Auslieferung: Derivate weg, Soft * Delete; der Nachholer archiviert das weich gelöschte Bild später. */ private function removeImage(PressReleaseImage $image, ?LegalRequest $legalRequest): bool { try { $this->imageRemover->remove($image, Auth::user(), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::Gdpr, $legalRequest); return true; } catch (Throwable $exception) { Log::error('Beweisarchiv: Bild aus Anonymisierung konnte nicht überführt werden, wird nachgeholt.', [ 'press_release_image_id' => $image->getKey(), 'error' => $exception->getMessage(), ]); $this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants); if (! $image->trashed()) { $image->delete(); } return false; } } /** * @param list $terms * @param list $removeImageIds */ private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, ?LegalRequest $legalRequest, array $terms, array $removeImageIds): ?ArchiveEntry { try { return $this->archiver->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($snapshot->encrypted_before['contact_ids'] ?? []).' Kontakt(e)', $snapshot->getKey(), ); } catch (Throwable $exception) { Log::error('Beweisarchiv: Anonymisierung konnte nicht archiviert werden, wird nachgeholt.', [ 'press_release_id' => $pressRelease->getKey(), 'snapshot_id' => $snapshot->getKey(), 'error' => $exception->getMessage(), ]); return null; } } /** * Ersetzung über Wortgrenzen: Lookarounds auf Unicode-Buchstaben/Ziffern * statt \b, damit Umlaute korrekt begrenzen und weder Teiltreffer * stehenbleiben noch Wörter wie „maximal" bei Begriff „Max" zerlegt * werden. Längste Begriffe zuerst („Matvey Teplitsky" vor „Teplitsky"). * * @param list $terms * @return array{0: string, 1: int} */ private function redact(string $value, array $terms, string $replacement): array { $totalHits = 0; $normalized = collect($terms) ->map(fn (string $term): string => trim($term)) ->filter() ->unique() ->sortByDesc(fn (string $term): int => mb_strlen($term)) ->values(); foreach ($normalized as $term) { $pattern = '/(? $paths */ private function deletePublicFiles(?string $disk, array $paths): void { if ($disk !== 'public' || $paths === []) { return; } Storage::disk('public')->delete($paths); } /** * Sitemap-Caches aller Hosts verwerfen (SitemapController cached 1h je * Host) — nach einer Schwärzung darf kein Cache den alten Stand halten. */ private function forgetSitemapCaches(): void { foreach ((array) config('domains.domains', []) as $domain) { $host = parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST); if ($host) { Cache::forget('web.sitemap.'.$host); } } } }