*/ public const REDACTABLE_FIELDS = ['title', 'subtitle', 'text', 'boilerplate_override', 'keywords']; public const DEFAULT_REPLACEMENT = '[entfernt]'; public function __construct(private readonly ActivityLog $activityLog) {} /** * 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 → * Bilder/Anhänge entfernen → Re-Sync-Sperre → 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; $snapshot = DB::transaction(function () use ( $pressRelease, $terms, $replacement, $detachContacts, $removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, ): 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); $changedFields = []; 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); $changedFields[] = $field; $totalHits += $hits; } } if ($changedFields !== []) { $pressRelease->save(); } if ($contactIds !== []) { $pressRelease->contacts()->detach($contactIds); } // Klarname in Pressebild/PDF: Zeile soft-löschen (raus aus der // Auslieferung); auf der public-Disk zusätzlich die Dateien // entfernen, weil sie sonst per Direkt-URL erreichbar blieben. foreach ($images as $image) { $this->deletePublicFiles($image->disk, array_filter([ $image->path, ...array_values((array) ($image->variants ?? [])), ])); $image->delete(); } foreach ($attachments as $attachment) { $this->deletePublicFiles($attachment->disk, array_filter([$attachment->path])); $attachment->delete(); } $pressRelease->lockSync( 'Anonymisierung im Meldeverfahren'.($legalRequest ? ' '.$legalRequest->reference() : '') ); return $snapshot; }); $this->forgetSitemapCaches(); // 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()], ); return $snapshot; } /** * 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); } } } }