archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)

- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:01 +00:00
parent f0eeef0144
commit 7cd84b2be3
29 changed files with 2918 additions and 412 deletions

View file

@ -8,20 +8,36 @@ use Illuminate\Console\Command;
/**
* Beweisarchiv (Block F.3): nach dem Einspielen einer Sicherung alles, was
* laut Tombstone-Export oder laut Datenbank bereits gelöscht war, erneut
* entfernen. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
* entfernen — samt Kopien in der Anwendung (A-06). Setzt den Orphan-Sweep
* aus (A-03); danach `archive:reregister`, dann `archive:sweep-orphans
* --resume`. Teil des Restore-Runbooks (dev/betrieb/deploy-runbook.md).
*/
class ArchiveReconcileAfterRestore extends Command
{
protected $signature = 'archive:reconcile-after-restore';
protected $signature = 'archive:reconcile-after-restore
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}';
protected $description = 'Entfernt nach einem Restore wiederhergestellte Beweisarchiv-Payloads anhand des Tombstone-Exports und der Datenbank-Tombstones';
public function handle(EvidenceArchive $archive): int
{
$result = $archive->reconcileAfterRestore();
$result = $archive->reconcileAfterRestore((bool) $this->option('accept-unsigned'));
$this->line('Tombstone-Export: '.match ($result['export_signature']) {
'valid' => 'Signatur gültig.',
'unsigned' => 'ohne Signatur — auf Anweisung (--accept-unsigned) verwendet.',
'unsigned_refused' => 'ohne Signatur — verworfen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
'invalid' => 'Signatur ungültig — Export verworfen, nur Datenbank-Tombstones abgeglichen.',
default => 'nicht vorhanden — nur Datenbank-Tombstones abgeglichen.',
});
$this->info(sprintf('Abgleich: %d Einträge erneut als gelöscht markiert, %d Dateien entfernt, %d fehlgeschlagen.', $result['re_tombstoned'], $result['files_removed'], $result['failed']));
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
if ($result['conflicts'] > 0) {
$this->error(sprintf('%d Export-Zeile(n) passen nicht zur Datenbank (gleiche ID, anderer Payload) — nicht angefasst, bitte im Log prüfen.', $result['conflicts']));
}
$this->warn('Orphan-Sweep ausgesetzt: jetzt archive:reregister ausführen, danach archive:sweep-orphans --resume.');
return $result['failed'] > 0 || $result['conflicts'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-03): nach einem DB-Restore
* die Zeilen für Payloads ohne Zeile aus deren manifest.json wieder
* anlegen — aus `entries/` und aus der Quarantäne. Laut Tombstone-Export
* gelöschte Payloads bleiben außen vor. Legal Hold kennt das Manifest
* nicht: wiederhergestellte Einträge im Admin prüfen.
*/
class ArchiveReregister extends Command
{
protected $signature = 'archive:reregister
{--dry-run : Nur zählen}
{--accept-unsigned : Unsignierten Tombstone-Export verwenden (nur Exporte vor dem 29.09.2026)}
{--without-export : Ohne Tombstone-Export registrieren (gelöschte Einträge können zurückkehren)}';
protected $description = 'Registriert Beweisarchiv-Payloads ohne Zeile anhand ihres Manifests neu (nach DB-Restore)';
public function handle(EvidenceArchive $archive): int
{
if (! $archive->isConfigured()) {
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — Payloads lassen sich nicht lesen.');
return self::FAILURE;
}
$result = $archive->reregister(
(bool) $this->option('dry-run'),
(bool) $this->option('accept-unsigned'),
(bool) $this->option('without-export'),
);
if ($result['aborted']) {
$this->error(match ($result['export_signature']) {
'invalid' => 'Tombstone-Export mit ungültiger Signatur — abgebrochen, nichts registriert. Ist ARCHIVE_PREVIOUS_KEYS vollständig?',
'unsigned_refused' => 'Tombstone-Export ohne Signatur — abgebrochen. Nur für einen Export von vor dem 29.09.2026 mit --accept-unsigned wiederholen.',
default => 'Kein Tombstone-Export — abgebrochen. Ohne ihn können gelöschte Einträge zurückkehren; bewusst mit --without-export.',
});
return self::FAILURE;
}
$this->info(sprintf(
'%d Payload(s) ohne Zeile, %d wieder registriert, %d laut Tombstone-Export gelöscht (übergangen), %d fehlgeschlagen.',
$result['candidates'],
$result['registered'],
$result['skipped_tombstoned'],
$result['failed'],
));
if ($result['registered'] > 0) {
$this->warn('Legal Hold und Kennzahlen stehen nicht im Manifest — wiederhergestellte Einträge im Admin prüfen.');
}
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -2,48 +2,60 @@
namespace App\Console\Commands;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Models\ArchivePendingJob;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Umsetzungsaudit 25.09.2026, N11): Meldungen mit gesetztem
* `archive_pending_at` — ihr Archiveintrag ist beim Vorgang fehlgeschlagen
* — erneut archivieren. Läuft täglich; bleibt etwas offen, endet der Lauf
* mit Fehlerstatus, und der Systemstatus zeigt die Zahl.
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-01/A-02): offene
* Archivaufträge (archive_pending_jobs) je Ereignistyp nachholen. Läuft
* täglich. Ohne gültigen ARCHIVE_KEY bricht der Lauf ab, ohne einen
* Auftrag anzufassen. Bleibt etwas offen, endet er mit Fehlerstatus, und
* der Systemstatus zeigt die Zahl.
*/
class ArchiveRetryPending extends Command
{
protected $signature = 'archive:retry-pending {--dry-run : Nur zählen}';
protected $description = 'Holt fehlgeschlagene Beweisarchiv-Einträge (archive_pending_at) nach';
protected $description = 'Holt ausstehende Beweisarchiv-Einträge (archive_pending_jobs) nach';
public function handle(PendingArchiveRetry $retry): int
public function handle(PendingArchiveRetry $retry, ArchivePendingQueue $queue, EvidenceArchive $archive): int
{
$query = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->orderBy('id');
$pending = $query->count();
if ($this->option('dry-run')) {
$this->info("Offen (Trockenlauf): {$pending} Meldungen mit ausstehender Archivierung.");
$status = $queue->status();
$this->info("Offen (Trockenlauf): {$status['jobs']} Archivaufträge, {$status['legacy_markers']} Altmarker ohne Auftrag.");
return self::SUCCESS;
}
$recorded = 0;
$failed = 0;
if (! $archive->isConfigured()) {
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — nichts nachgeholt, alle Aufträge bleiben offen.');
$query->chunkById(100, function ($pressReleases) use ($retry, &$recorded, &$failed): void {
foreach ($pressReleases as $pressRelease) {
$result = $retry->retry($pressRelease);
$recorded += $result['recorded'];
$failed += $result['failed'];
return self::FAILURE;
}
$queue->adoptLegacyMarkers();
$pending = ArchivePendingJob::query()->count();
$totals = ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
ArchivePendingJob::query()->lazyById(100)->each(function (ArchivePendingJob $job) use ($retry, &$totals): void {
foreach ($retry->retry($job) as $key => $count) {
$totals[$key] += $count;
}
});
$remaining = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count();
$this->info(sprintf('Nachgeholt: %d Archiveinträge für %d Meldungen; fehlgeschlagen: %d; weiterhin offen: %d.', $recorded, $pending, $failed, $remaining));
$remaining = ArchivePendingJob::query()->count();
$this->info(sprintf(
'Nachgeholt: %d Archiveinträge aus %d Aufträgen; übersprungen: %d; fehlgeschlagen: %d; weiterhin offen: %d.',
$totals['recorded'],
$pending,
$totals['skipped'],
$totals['failed'],
$remaining,
));
return $failed > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
return $totals['failed'] > 0 || $totals['skipped'] > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -6,30 +6,57 @@ use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Umsetzungsaudit 25.09.2026, N11): Payload-Dateien ohne
* Archivzeile (abgebrochene Ablage, äußerer Rollback, liegengebliebene
* .tmp) nach Karenzzeit entfernen. Läuft täglich nach dem Purge.
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-03): Payload-Dateien ohne
* Archivzeile nach Karenzzeit in die Quarantäne verschieben (nicht löschen
* — nach einem DB-Restore fehlen echten Einträgen die Zeilen), liegen-
* gebliebene .tmp löschen, abgelaufene Quarantäne-Tage entfernen. Läuft
* täglich nach dem Purge. `--pause`/`--resume` schalten den Sweep aus und
* ein; der Restore-Abgleich setzt ihn automatisch aus.
*/
class ArchiveSweepOrphans extends Command
{
protected $signature = 'archive:sweep-orphans
{--hours=24 : Nur Dateien, die älter als X Stunden sind}
{--dry-run : Nur zählen}';
{--dry-run : Nur zählen}
{--pause : Sweep aussetzen (z. B. nach einem Restore)}
{--resume : Ausgesetzten Sweep wieder aufnehmen}';
protected $description = 'Entfernt Beweisarchiv-Payloads ohne zugehörige Archivzeile';
protected $description = 'Verschiebt Beweisarchiv-Payloads ohne zugehörige Archivzeile in die Quarantäne';
public function handle(EvidenceArchive $archive): int
{
if ($this->option('pause')) {
$archive->pauseSweep();
$this->info('Orphan-Sweep ausgesetzt. Fortsetzen mit archive:sweep-orphans --resume (nach archive:reregister).');
return self::SUCCESS;
}
if ($this->option('resume')) {
$archive->resumeSweep();
$this->info('Orphan-Sweep wieder aktiv'.(config('archive.sweep_paused') ? ' — ARCHIVE_SWEEP_PAUSED hält ihn weiterhin an.' : '.'));
return self::SUCCESS;
}
$result = $archive->sweepOrphans((bool) $this->option('dry-run'), max(1, (int) $this->option('hours')));
if ($result['paused']) {
$this->warn('Orphan-Sweep ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nichts verschoben. Fortsetzen mit --resume.');
return self::SUCCESS;
}
$this->info(sprintf(
'%s: %d Dateien geprüft, %d verwaist, %d entfernt.',
'%s: %d Dateien geprüft, %d verwaist, %d in Quarantäne, %d entfernt; Quarantäne abgelaufen: %d.',
$this->option('dry-run') ? 'Trockenlauf' : 'Bereinigung',
$result['scanned'],
$result['orphans'],
$result['quarantined'],
$result['removed'],
$result['expired'],
));
return ! $this->option('dry-run') && $result['removed'] < $result['orphans'] ? self::FAILURE : self::SUCCESS;
return ! $this->option('dry-run') && $result['orphans'] > $result['quarantined'] + $result['removed'] ? self::FAILURE : self::SUCCESS;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\Archive\EvidenceArchive;
use Illuminate\Console\Command;
use ZipArchive;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-15): Signatur eines
* Anwaltsexports prüfen — neue Exporte (abgeleiteter Schlüssel) und
* Exporte vor der Umstellung (Archivschlüssel) gegen alle bekannten
* Schlüssel, dazu den Hash von payload.zip.
*/
class ArchiveVerifyExport extends Command
{
protected $signature = 'archive:verify-export {path : Pfad zur exportierten ZIP-Datei}';
protected $description = 'Prüft Hash und HMAC-Signatur eines Beweisarchiv-Exports';
public function handle(EvidenceArchive $archive): int
{
$zip = new ZipArchive;
if ($zip->open((string) $this->argument('path')) !== true) {
$this->error('Datei ist kein lesbares ZIP.');
return self::FAILURE;
}
$payload = $zip->getFromName('payload.zip');
$signature = $zip->getFromName('signature.txt');
$metadata = json_decode((string) $zip->getFromName('metadata.json'), true);
$zip->close();
$hash = is_array($metadata) ? (string) ($metadata['payload_sha256'] ?? '') : '';
if (! is_string($payload) || ! is_string($signature) || $hash === '' || ! hash_equals($hash, hash('sha256', $payload))) {
$this->error('Export ungültig: payload.zip passt nicht zum Hash in metadata.json.');
return self::FAILURE;
}
if (! $archive->verifyExportSignature($hash, $signature)) {
$this->error('Signatur ungültig oder mit keinem bekannten Schlüssel prüfbar.');
return self::FAILURE;
}
$this->info('Export gültig: Hash und Signatur stimmen.');
return self::SUCCESS;
}
}

View file

@ -7,6 +7,8 @@ use App\Models\PressRelease;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Archiviert Entwürfe, die seit mehr als X Tagen nicht bearbeitet wurden,
@ -15,9 +17,15 @@ use Illuminate\Database\Eloquent\Builder;
* Schützt vor "Zombie-Drafts" in der DB – optional, konfigurierbar.
*
* Abgelehnte Meldungen behalten ihren Status (Ablehnungsdatensatz bleibt
* als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Altbestand
* mit `published_ever_at` (im Altsystem „offline genommen") bleibt
* unangetastet.
* als Vertragsdokumentation, DU 4.1); nur die Medien fallen. Was
* möglicherweise veröffentlicht war, bleibt unangetastet — maßgeblich ist
* PressRelease::neverPublished(): `published_ever_at` und der Altbestand
* `rejected` (im Altsystem auch „offline genommen", Umsetzungsaudit
* 29.09.2026 A-05).
*
* Ein defektes Bild bricht den Lauf nicht ab (A-14): der Fehler wird
* gezählt, die Meldung behält ihren Status für den nächsten Lauf, und der
* Befehl endet mit Fehlerstatus.
*/
class PurgeExpiredPressReleaseDrafts extends Command
{
@ -41,7 +49,8 @@ class PurgeExpiredPressReleaseDrafts extends Command
// Bilder hängen — sonst wäre der Lauf jede Woche derselbe Zähler.
$rejected = PressRelease::withoutGlobalScopes()
->where('status', PressReleaseStatus::Rejected->value)
->whereNull('published_ever_at')
->neverPublished()
->whereNull('legacy_id')
->where('updated_at', '<', $cutoff)
->whereHas('images');
@ -57,32 +66,55 @@ class PurgeExpiredPressReleaseDrafts extends Command
// Bilder nie veröffentlichter Entwürfe werden mit dem Purge sofort
// und vollständig gelöscht (Bildkennzeichnung E.3, Decision-Update E4).
$remover = app(PressReleaseImageRemover::class);
$imagesRemoved = $this->removeImages((clone $drafts)->whereNull('published_ever_at'), $remover)
+ $this->removeImages(clone $rejected, $remover);
$failedPressReleaseIds = [];
$imagesRemoved = $this->removeImages((clone $drafts)->neverPublished(), $remover, $failedPressReleaseIds)
+ $this->removeImages(clone $rejected, $remover, $failedPressReleaseIds);
$drafts->update(['status' => PressReleaseStatus::Archived->value]);
// Entwürfe mit gescheiterter Bildlöschung behalten ihren Status,
// sonst fielen sie aus dem nächsten Lauf heraus.
$archived = $drafts->when($failedPressReleaseIds !== [], fn (Builder $query) => $query->whereKeyNot($failedPressReleaseIds))
->update(['status' => PressReleaseStatus::Archived->value]);
$this->info("PM-Entwürfe archiviert: {$draftCount} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}.");
$failed = count($failedPressReleaseIds);
$this->info("PM-Entwürfe archiviert: {$archived} Einträge, abgelehnte Einreichungen bereinigt: {$rejectedCount}, Bilder gelöscht: {$imagesRemoved}, Meldungen mit fehlgeschlagener Bildlöschung: {$failed}.");
if ($failed > 0) {
$this->error("Bildlöschung fehlgeschlagen: {$failed} Meldung(en) — Details im Log, sie werden beim nächsten Lauf wiederholt.");
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* @param Builder<PressRelease> $query
* @param list<int> $failedPressReleaseIds
*/
private function removeImages(Builder $query, PressReleaseImageRemover $remover): int
private function removeImages(Builder $query, PressReleaseImageRemover $remover, array &$failedPressReleaseIds): int
{
$removed = 0;
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed): void {
$query->with('images')->chunkById(200, function ($pressReleases) use ($remover, &$removed, &$failedPressReleaseIds): void {
foreach ($pressReleases as $pressRelease) {
foreach ($pressRelease->images as $image) {
$remover->remove($image);
$removed++;
try {
$remover->remove($image);
$removed++;
} catch (Throwable $exception) {
$failedPressReleaseIds[] = (int) $pressRelease->getKey();
Log::error('purge-drafts: Bild konnte nicht gelöscht werden, wird beim nächsten Lauf wiederholt.', [
'press_release_id' => $pressRelease->getKey(),
'press_release_image_id' => $image->getKey(),
'error' => $exception->getMessage(),
]);
}
}
}
});
$failedPressReleaseIds = array_values(array_unique($failedPressReleaseIds));
return $removed;
}
}

View file

@ -2,14 +2,20 @@
namespace App\Console\Commands;
use App\Models\ArchivePendingJob;
use App\Models\PressReleaseRedactionSnapshot;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Löscht verschlüsselte Vorher-Snapshots des Meldeverfahrens nach Ablauf
* ihrer Löschfrist (purge_after, gesetzt aus legal.snapshot_retention_days).
* Danach ist eine Schwärzung endgültig — genau so gewollt: Alt-Werte dürfen
* nicht unbegrenzt weiterleben. Läuft täglich via Scheduler.
*
* Ausgenommen sind Meldungen mit offenem Archivauftrag oder Marker
* (Umsetzungsaudit 29.09.2026, A-13): der Nachholer braucht den
* Vorher-Stand noch; der Snapshot fällt im ersten Lauf danach.
*/
class PurgeRedactionSnapshots extends Command
{
@ -21,6 +27,8 @@ class PurgeRedactionSnapshots extends Command
{
$deleted = PressReleaseRedactionSnapshot::query()
->dueForPurge()
->whereNotIn('press_release_id', ArchivePendingJob::query()->select('press_release_id'))
->whereNotIn('press_release_id', DB::table('press_releases')->whereNotNull('archive_pending_at')->select('id'))
->delete();
$this->info("Schwärzungs-Snapshots bereinigt: {$deleted} Einträge gelöscht.");

View file

@ -10,9 +10,10 @@ 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.
* Öffnen und Export von Archiveinträgen (Beweisarchiv F.2): GET zeigt ein
* kleines Formular, erst der POST mit Zweck (CSRF) liefert den Download,
* und der Zugriff steht im Protokoll. Der Zweck steht nie in der URL und
* damit nicht in Server-Logs (Umsetzungsaudit 29.09.2026, A-08).
*/
class ArchiveEntryController extends Controller
{
@ -32,10 +33,15 @@ class ArchiveEntryController extends Controller
{
abort_if($entry->isTombstoned(), 410, 'Der Eintrag ist nach Fristablauf gelöscht (Tombstone).');
$purpose = trim((string) $request->input('purpose', ''));
$purpose = $request->isMethod('post') ? trim((string) $request->request->get('purpose', '')) : '';
if ($purpose === '') {
return view('admin.archive.purpose', ['entry' => $entry, 'action' => $action]);
return view('admin.archive.purpose', [
'formAction' => route($action === 'export' ? 'admin.archive.export' : 'admin.archive.download', ['entry' => $entry->id]),
'description' => __('Archiveintrag #:id (:event). Jeder Zugriff auf das Beweisarchiv wird mit Zweck protokolliert.', ['id' => $entry->id, 'event' => $entry->event->label()]),
'submitLabel' => $action === 'export' ? __('Export herunterladen') : __('Payload öffnen'),
'cancelUrl' => route('admin.archive.index'),
]);
}
$bytes = $action === 'export'

View file

@ -2,13 +2,16 @@
namespace App\Http\Controllers\Admin;
use App\Authorization\Permission;
use App\Enums\ActivityType;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -18,30 +21,51 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
* Abruf im Aktivitätsverlauf. Medienroute, Vorschau-Signaturen und API
* kennen diese Pfade nicht. Block F verlagert das Protokoll in
* `archive_access_log`.
*
* Umsetzungsaudit 29.09.2026: Der Zweck kommt nur per POST (A-08), GET
* zeigt das Zweckformular. Restnachweise entfernter Bilder (weich
* gelöschte Zeile) nur mit `compliance:manage` (A-11) — wie das Archiv.
*/
class PressReleaseImageEvidenceController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function original(Request $request, int $id, int $image): StreamedResponse
public function original(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'original');
}
public function published(Request $request, int $id, int $image): StreamedResponse
public function published(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'published');
}
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse|View
{
$purpose = trim((string) $request->query('purpose', ''));
abort_if($purpose === '', 422, 'Zweck des Zugriffs fehlt (purpose).');
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
if ($image->trashed()) {
Gate::authorize(Permission::ComplianceManage);
}
$purpose = $request->isMethod('post') ? trim((string) $request->request->get('purpose', '')) : '';
if ($purpose === '') {
abort_if($request->isMethod('post'), 422, 'Zweck des Zugriffs fehlt (purpose).');
return view('admin.archive.purpose', [
'formAction' => route($kind === 'original' ? 'admin.press-releases.images.original' : 'admin.press-releases.images.published', ['id' => $pressRelease->getKey(), 'image' => $image->getKey()]),
'description' => __(':kind zu Bild #:image (PM #:pr). Jeder Abruf wird mit Zweck protokolliert.', [
'kind' => $kind === 'original' ? __('Beweisoriginal') : __('Veröffentlichte Fassung'),
'image' => $image->getKey(),
'pr' => $pressRelease->getKey(),
]),
'submitLabel' => __('Datei öffnen'),
'cancelUrl' => route('admin.press-releases.show', ['id' => $pressRelease->getKey()]),
]);
}
$path = $kind === 'original' ? $image->original_path : $image->published_snapshot_path;
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));

View file

@ -7,7 +7,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Lesender Zugriff auf einen Archiveintrag (Beweisarchiv 4.3): wer, wann,
* wozu. Insert-only.
* wozu. Insert-only. Seit dem Umsetzungsaudit 29.09.2026 (A-07) auch
* Legal Hold setzen/aufheben (`hold_set`/`hold_released`, Vermerk als
* Zweck) und Name/E-Mail des Admins, damit die Zeile eine Kontolöschung
* überlebt.
*/
class ArchiveAccessLog extends Model
{
@ -15,7 +18,7 @@ class ArchiveAccessLog extends Model
protected $table = 'archive_access_log';
protected $fillable = ['entry_id', 'admin_id', 'action', 'purpose', 'accessed_at'];
protected $fillable = ['entry_id', 'admin_id', 'admin_name', 'admin_email', 'action', 'purpose', 'accessed_at'];
protected function casts(): array
{

View file

@ -37,6 +37,7 @@ class ArchiveEntry extends Model
'payload_path',
'payload_hash',
'payload_bytes',
'key_id',
'retain_until',
'legal_hold',
'legal_hold_note',

View file

@ -0,0 +1,74 @@
<?php
namespace App\Models;
use App\Enums\ArchiveReasonCategory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Ausstehender Beweisarchiv-Eintrag (Umsetzungsaudit 29.09.2026, A-02):
* einer je Vorgang, angelegt in der fachlichen Transaktion, gelöscht mit
* dem geschriebenen Eintrag. `event` ist ein Wert aus ArchiveEvent oder
* eine der Sonderarten unten. Anlegen und Abschließen nur über
* App\Services\Archive\ArchivePendingQueue.
*
* @property int $id
* @property int $press_release_id
* @property string $event
* @property string|null $subject_type
* @property int|null $subject_id
* @property ArchiveReasonCategory|null $reason_category
* @property int|null $legal_request_id
* @property int|null $actor_id
* @property string|null $note
* @property array<string, mixed>|null $payload
* @property int $attempts
* @property string|null $last_error
* @property Carbon|null $last_attempted_at
* @property Carbon|null $created_at
*/
class ArchivePendingJob extends Model
{
/**
* Nie veröffentlichtes Bild, dessen Sofortlöschung gescheitert ist
* (A-10): kein Archiveintrag, sondern die Löschung wird nachgeholt.
*/
public const EVENT_IMAGE_PURGE = 'image_purge';
/**
* Marker `archive_pending_at` aus der Zeit vor den Aufträgen (Deploy
* vom 28.09.2026). Der Nachholer wendet darauf die bisherige Heuristik
* einmalig an.
*/
public const EVENT_LEGACY_MARKER = 'legacy_marker';
public const UPDATED_AT = null;
protected $fillable = [
'press_release_id',
'event',
'subject_type',
'subject_id',
'reason_category',
'legal_request_id',
'actor_id',
'note',
'payload',
'attempts',
'last_error',
'last_attempted_at',
'created_at',
];
protected function casts(): array
{
return [
'reason_category' => ArchiveReasonCategory::class,
'payload' => 'encrypted:array',
'attempts' => 'integer',
'last_attempted_at' => 'datetime',
'created_at' => 'datetime',
];
}
}

View file

@ -3,10 +3,11 @@
namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog;
use App\Models\PressRelease;
use App\Models\ArchiveEntry;
use App\Models\PressReleaseAttachment;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@ -212,17 +213,53 @@ class AdminSystemHealth
: $provenanceFailures.' Bild-Upload(s) mit Status „nicht prüfbar“ in den letzten 7 Tagen (Imagick/exif prüfen).',
];
// Beweisarchiv: Vorgänge, deren Archiveintrag fehlgeschlagen ist
// (archive_pending_at, Umsetzungsaudit N11), und weich gelöschte
// v2-Bilder, deren lokale Beweisdateien noch nicht bereinigt sind (5.1 e).
$archivePending = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count();
// Beweisarchiv: Schlüssel (Umsetzungsaudit 29.09.2026, A-01/A-15) —
// ohne ARCHIVE_KEY wird nichts archiviert und der Nachholer bricht
// ab; Einträge mit einem nicht mehr konfigurierten Schlüssel sind
// unlesbar.
$archive = app(EvidenceArchive::class);
$unknownKeyEntries = $archive->isConfigured()
? ArchiveEntry::query()->whereNull('deleted_at')->whereNotNull('key_id')->whereNotIn('key_id', $archive->knownKeyIds())->count()
: 0;
$checks[] = [
'key' => 'archive_key',
'label' => 'Beweisarchiv-Schlüssel',
'status' => ! $archive->isConfigured() || $unknownKeyEntries > 0 ? self::StatusCritical : self::StatusOk,
'detail' => match (true) {
! $archive->isConfigured() => 'ARCHIVE_KEY fehlt oder ist ungültig — es wird nichts archiviert, `archive:retry-pending` bricht ab. Schlüssel aus der getrennten Sicherung setzen, `config:cache`.',
$unknownKeyEntries > 0 => $unknownKeyEntries.' Archiveintrag/-einträge mit unbekanntem Schlüssel — früheren Schlüssel in ARCHIVE_PREVIOUS_KEYS ergänzen.',
default => 'ARCHIVE_KEY gesetzt (Schlüssel-ID '.$archive->keyId().', '.(count($archive->knownKeyIds()) - 1).' frühere).',
},
];
// Offene Archivaufträge (N11, A-02) und Altmarker ohne Auftrag;
// dazu weich gelöschte v2-Bilder, deren lokale Beweisdateien noch
// nicht bereinigt sind (5.1 e).
$pending = app(ArchivePendingQueue::class)->status();
$pendingTotal = $pending['jobs'] + $pending['legacy_markers'];
$checks[] = [
'key' => 'archive_pending',
'label' => 'Beweisarchiv-Nachholer',
'status' => $archivePending === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $archivePending === 0
'status' => $pendingTotal === 0 ? self::StatusOk : self::StatusWarn,
'detail' => $pendingTotal === 0
? 'Alle Archiveinträge wurden beim Vorgang geschrieben.'
: $archivePending.' Meldung(en) mit ausstehendem Archiveintrag — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.',
: sprintf(
'%d offene(r) Archivauftrag/-aufträge%s%s — `archive:retry-pending` läuft täglich; Log auf ARCHIVE_KEY/Archiv-Disk prüfen.',
$pending['jobs'],
$pending['oldest'] !== null ? ' (ältester seit '.$pending['oldest']->format('d.m.Y H:i').')' : '',
$pending['legacy_markers'] > 0 ? ', '.$pending['legacy_markers'].' Altmarker ohne Auftrag' : '',
),
];
// Orphan-Sweep ausgesetzt (Restore-Abgleich, A-03).
$sweepPaused = $archive->isSweepPaused();
$checks[] = [
'key' => 'archive_sweep',
'label' => 'Beweisarchiv-Orphan-Sweep',
'status' => $sweepPaused ? self::StatusWarn : self::StatusOk,
'detail' => $sweepPaused
? 'Ausgesetzt (Restore-Abgleich oder ARCHIVE_SWEEP_PAUSED) — nach `archive:reregister` mit `archive:sweep-orphans --resume` fortsetzen.'
: 'Aktiv; verwaiste Payloads wandern in die Quarantäne ('.(int) config('archive.quarantine_days', 30).' Tage).',
];
$evidenceLeftovers = PressReleaseImageRemover::countLocalEvidenceLeftovers();

View file

@ -0,0 +1,266 @@
<?php
namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\PressReleaseImage;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Aufträge für ausstehende Beweisarchiv-Einträge (Umsetzungsaudit
* 29.09.2026, A-02/A-04/A-09/A-10).
*
* Ablauf für jeden archivpflichtigen Vorgang:
*
* $job = DB::transaction(function () use (…) {
* // fachliche Änderung …
* return $queue->push($pressReleaseId, ArchiveEvent::LabelCorrected, $correction,
* ArchiveReasonCategory::Correction, actorId: $admin->id);
* });
* $entry = $queue->completeWith($job, fn () => $archiver->labelCorrected(…));
*
* push() gehört in die fachliche Transaktion: Auftrag und Marker
* `press_releases.archive_pending_at` entstehen oder fallen mit dem
* Vorgang. completeWith() läuft nach dem Commit, wirft nie und löscht den
* Auftrag nur zusammen mit dem geschriebenen Eintrag. Scheitert die
* Ablage, bleibt der Auftrag stehen und `archive:retry-pending` holt ihn
* je Ereignistyp nach (PendingArchiveRetry).
*
* Der Marker ist nur noch ein Sammelanzeiger: gesetzt, wenn er NULL ist
* (ein älterer Wert bleibt), gelöscht, wenn für die Meldung kein Auftrag
* mehr offen ist — beides per Query Builder, `updated_at` bleibt
* unberührt. Ein erfolgreicher späterer Vorgang kann einen älteren offenen
* Auftrag damit weder überschreiben noch mitlöschen.
*/
class ArchivePendingQueue
{
/**
* Auftrag anlegen (innerhalb der fachlichen Transaktion aufrufen).
*
* @param Model|null $subject Bezug des späteren Eintrags bzw. des Nachholers:
* Meldung, Redaction-Snapshot, Bild oder Korrekturzeile
* @param array<string, mixed> $payload Was der Nachholer braucht (z. B. `state` bei
* `pm_depublished`, `before`/`after` bei `gdpr_change`)
*/
public function push(
int $pressReleaseId,
ArchiveEvent $event,
?Model $subject = null,
?ArchiveReasonCategory $reason = null,
?int $legalRequestId = null,
?int $actorId = null,
?string $note = null,
array $payload = [],
): ArchivePendingJob {
return $this->create($pressReleaseId, $event->value, $subject, $reason, $legalRequestId, $actorId, $note, $payload);
}
/**
* Sofortlöschung eines nie veröffentlichten Bildes ist gescheitert
* (A-10): der Nachholer wiederholt sie, der Systemstatus zählt den Rest.
*/
public function pushImagePurge(PressReleaseImage $image, ?string $error = null): ArchivePendingJob
{
$job = $this->create((int) $image->press_release_id, ArchivePendingJob::EVENT_IMAGE_PURGE, $image, null, null, null, 'Sofortlöschung fehlgeschlagen', []);
if ($error !== null) {
$this->recordFailure($job, $error);
}
return $job;
}
/**
* Archiveintrag schreiben und den Auftrag im selben Schritt abschließen.
* Wirft nie: ein Fehler oder ein nicht geschriebener Eintrag (fehlender
* Schlüssel) zählt als Versuch, der Auftrag bleibt offen.
*
* @param callable(): ?ArchiveEntry $step
*/
public function completeWith(ArchivePendingJob $job, callable $step): ?ArchiveEntry
{
$alreadyCompleted = false;
try {
$entry = DB::transaction(function () use ($job, $step, &$alreadyCompleted): ?ArchiveEntry {
// Erst beanspruchen, dann archivieren (Umsetzungsaudit
// 02.10.2026, O07): Nachholer und Direktweg dürfen denselben
// Auftrag nicht beide ablegen.
if ($this->claim($job) === null) {
$alreadyCompleted = true;
return null;
}
$entry = $step();
if ($entry !== null) {
$this->complete($job);
}
return $entry;
});
} catch (Throwable $exception) {
$this->recordFailure($job, $exception->getMessage());
Log::error('Beweisarchiv: Eintrag konnte nicht geschrieben werden, Auftrag bleibt offen.', [
'job_id' => $job->getKey(),
'event' => $job->event,
'press_release_id' => $job->press_release_id,
'error' => $exception->getMessage(),
]);
return null;
}
if ($entry === null && ! $alreadyCompleted) {
$this->recordFailure($job, 'Eintrag wurde nicht archiviert (ARCHIVE_KEY fehlt oder ist ungültig).');
}
return $entry;
}
/**
* Auftragszeile unter Sperre neu lesen — innerhalb der Transaktion, die
* den Eintrag schreibt. Wer zweiter ist, wartet auf die Sperre und findet
* den Auftrag danach nicht mehr (null): dann ist nichts mehr zu tun.
*/
public function claim(ArchivePendingJob $job): ?ArchivePendingJob
{
return ArchivePendingJob::query()->whereKey($job->getKey())->lockForUpdate()->first();
}
/**
* Auftrag löschen; den Sammelmarker nur leeren, wenn für die Meldung
* kein weiterer Auftrag offen ist.
*/
public function complete(ArchivePendingJob $job): void
{
ArchivePendingJob::query()->whereKey($job->getKey())->delete();
DB::table('press_releases')
->where('id', $job->press_release_id)
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->update(['archive_pending_at' => null]);
}
public function recordFailure(ArchivePendingJob $job, string $error): void
{
ArchivePendingJob::query()->whereKey($job->getKey())->update([
'attempts' => DB::raw('attempts + 1'),
'last_error' => Str::limit($error, 990, ''),
'last_attempted_at' => now(),
]);
}
/**
* Marker aus der Zeit vor den Aufträgen (Deploy 28.09.2026) in je einen
* Auftrag `legacy_marker` überführen, damit sie nicht verloren gehen.
*/
public function adoptLegacyMarkers(): int
{
$adopted = 0;
DB::table('press_releases')
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->select(['id'])
->lazyById(200)
->each(function (object $row) use (&$adopted): void {
$this->adoptLegacyMarker((int) $row->id);
$adopted++;
});
return $adopted;
}
/**
* Offene Aufträge plus Altmarker ohne Auftrag (Systemstatus).
*
* @return array{jobs: int, legacy_markers: int, oldest: ?Carbon}
*/
public function status(): array
{
$oldest = ArchivePendingJob::query()->min('created_at');
return [
'jobs' => ArchivePendingJob::query()->count(),
'legacy_markers' => DB::table('press_releases')
->whereNotNull('archive_pending_at')
->whereNotExists(function ($query): void {
$query->select(DB::raw(1))
->from('archive_pending_jobs')
->whereColumn('archive_pending_jobs.press_release_id', 'press_releases.id');
})
->count(),
'oldest' => $oldest !== null ? Carbon::parse($oldest) : null,
];
}
/**
* @param array<string, mixed> $payload
*/
private function create(int $pressReleaseId, string $event, ?Model $subject, ?ArchiveReasonCategory $reason, ?int $legalRequestId, ?int $actorId, ?string $note, array $payload): ArchivePendingJob
{
return DB::transaction(function () use ($pressReleaseId, $event, $subject, $reason, $legalRequestId, $actorId, $note, $payload): ArchivePendingJob {
$this->adoptLegacyMarker($pressReleaseId);
$job = ArchivePendingJob::query()->create([
'press_release_id' => $pressReleaseId,
'event' => $event,
'subject_type' => $subject?->getMorphClass(),
'subject_id' => $subject?->getKey(),
'reason_category' => $reason,
'legal_request_id' => $legalRequestId,
'actor_id' => $actorId,
'note' => $note !== null ? Str::limit($note, 490, '') : null,
'payload' => $payload,
'created_at' => now(),
]);
DB::table('press_releases')
->where('id', $pressReleaseId)
->whereNull('archive_pending_at')
->update(['archive_pending_at' => now()]);
return $job;
});
}
/**
* Steht ein Marker ohne Auftrag, stammt er aus der Zeit vor den
* Aufträgen: als eigener Auftrag festhalten, bevor ein neuer Vorgang
* ihn „übernimmt".
*/
private function adoptLegacyMarker(int $pressReleaseId): void
{
$marker = DB::table('press_releases')->where('id', $pressReleaseId)->value('archive_pending_at');
if ($marker === null || ArchivePendingJob::query()->where('press_release_id', $pressReleaseId)->exists()) {
return;
}
ArchivePendingJob::query()->create([
'press_release_id' => $pressReleaseId,
'event' => ArchivePendingJob::EVENT_LEGACY_MARKER,
'payload' => [],
'created_at' => Carbon::parse($marker),
]);
}
}

View file

@ -4,6 +4,7 @@ namespace App\Services\Archive;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\AppSetting;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\ImageDeclarationCorrection;
@ -11,9 +12,11 @@ use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Support\FileDeletion;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@ -38,14 +41,50 @@ use ZipArchive;
* ein Tombstone entsteht erst nach bestätigter Löschung, Purge und
* Restore-Abgleich melden Fehlschläge, der Export wird atomar ersetzt.
* Hold und Purge koordinieren sich über dieselbe Zeilensperre (N02).
*
* Umsetzungsaudit 29.09.2026:
* - Schlüsselrotation (A-15): jeder Eintrag trägt die Schlüssel-ID; frühere
* Schlüssel stehen in ARCHIVE_PREVIOUS_KEYS. Signaturen (Anwaltsexport,
* Tombstone-Export) nutzen per HKDF abgeleitete eigene Schlüssel.
* - Der Orphan-Sweep löscht nicht mehr, er verschiebt in die Quarantäne
* (A-03); er lässt sich aussetzen, der Restore-Abgleich setzt ihn aus,
* `reregister()` stellt Zeilen aus dem Manifest wieder her.
* - Hold setzen/aufheben steht im Zugriffsprotokoll (A-07).
*/
class EvidenceArchive
{
/** AppSetting-Schalter: Orphan-Sweep ausgesetzt (Restore, A-03). */
public const SWEEP_PAUSED_SETTING = 'archive.sweep_paused';
private const EXPORT_SIGNATURE_CONTEXT = 'pk-archive:export-signature:v1';
private const TOMBSTONE_EXPORT_CONTEXT = 'pk-archive:tombstone-export:v1';
public function isConfigured(): bool
{
return $this->keyBytes() !== null;
}
/**
* Kennung des aktuellen Schlüssels (16 Hex-Zeichen), null ohne Schlüssel.
*/
public function keyId(): ?string
{
$key = $this->keyBytes();
return $key !== null ? $this->idForKey($key) : null;
}
/**
* Kennungen aller bekannten Schlüssel (aktuell zuerst).
*
* @return list<string>
*/
public function knownKeyIds(): array
{
return array_keys($this->keyring());
}
/**
* Payload zuerst als .tmp, dann die Zeile in einer Transaktion, dann
* die Datei an ihren Platz — schlägt etwas fehl, bleibt weder Zeile
@ -113,6 +152,7 @@ class EvidenceArchive
'payload_path' => $path,
'payload_hash' => $hash,
'payload_bytes' => strlen($zip),
'key_id' => $this->keyId(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
'created_at' => now(),
]);
@ -149,27 +189,14 @@ class EvidenceArchive
{
$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) : [];
return $this->manifestFromZip($this->plaintext($entry));
}
/**
* Export für den Anwalt: äußeres ZIP mit payload.zip, metadata.json und
* einer HMAC-Signatur über den Hash (mit dem Archivschlüssel).
* einer HMAC-Signatur über den Hash. Der Signaturschlüssel ist per HKDF
* vom Archivschlüssel abgeleitet (A-15), nicht der Verschlüsselungs-
* schlüssel selbst; verifyExportSignature() prüft neue und alte Exporte.
*/
public function export(ArchiveEntry $entry, User $admin, string $purpose): string
{
@ -192,10 +219,47 @@ class EvidenceArchive
'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",
'signature.txt' => 'sha256='.hash('sha256', $payload)
."\nkey-id=".$this->keyId()
."\nhmac-sha256-hkdf=".hash_hmac('sha256', (string) $entry->payload_hash, $this->derivedKey((string) $this->keyBytes(), self::EXPORT_SIGNATURE_CONTEXT))."\n",
], 'metadata.json');
}
/**
* Signatur eines Anwaltsexports prüfen. Neu: `hmac-sha256-hkdf=` mit
* abgeleitetem Schlüssel (Schlüssel-ID in `key-id=`); Exporte vor dem
* 29.09.2026 tragen `hmac-sha256=` mit dem Archivschlüssel selbst —
* beides wird gegen alle bekannten Schlüssel geprüft.
*/
public function verifyExportSignature(string $payloadHash, string $signature): bool
{
$fields = [];
foreach (preg_split('/\R/', trim($signature)) ?: [] as $line) {
[$name, $value] = array_pad(explode('=', $line, 2), 2, '');
$fields[trim($name)] = trim($value);
}
$keys = $this->keyring();
if (isset($fields['key-id']) && isset($keys[$fields['key-id']])) {
$keys = [$fields['key-id'] => $keys[$fields['key-id']]];
}
foreach ($keys as $key) {
if (isset($fields['hmac-sha256-hkdf'])
&& hash_equals(hash_hmac('sha256', $payloadHash, $this->derivedKey($key, self::EXPORT_SIGNATURE_CONTEXT)), $fields['hmac-sha256-hkdf'])) {
return true;
}
if (isset($fields['hmac-sha256']) && hash_equals(hash_hmac('sha256', $payloadHash, $key), $fields['hmac-sha256'])) {
return true;
}
}
return false;
}
/**
* Hold setzen unter Zeilensperre: ein gleichzeitig laufender Purge sieht
* den Hold bei seiner erneuten Prüfung, oder der Hold wartet, bis der
@ -217,6 +281,10 @@ class EvidenceArchive
'legal_hold_set_by' => $admin->getKey(),
'legal_hold_set_at' => now(),
])->save();
// Historie (A-07): die Felder am Eintrag zeigen nur den letzten
// Stand, das Protokoll jeden Wechsel mit Vermerk.
$this->logAccess($locked, $admin, 'hold_set', $note);
});
$entry->refresh();
@ -241,6 +309,8 @@ class EvidenceArchive
'legal_hold_set_at' => now(),
'retain_until' => ArchiveEntry::retentionEndFor(now()),
])->save();
$this->logAccess($locked, $admin, 'hold_released', $note);
});
$entry->refresh();
@ -284,29 +354,61 @@ class EvidenceArchive
/**
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
* atomar ersetzt; ein Fehlschlag wirft, statt eine Anzahl zu melden.
* gestreamt geschrieben (OPT-6) und atomar ersetzt; ein Fehlschlag
* wirft, statt eine Anzahl zu melden. Das Feld `hmac` (HKDF-Schlüssel,
* A-15) schützt den Abgleich davor, auf eine untergeschobene Liste hin
* echte Payloads zu löschen.
*/
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();
$path = (string) config('archive.tombstone_export_path');
$temp = $path.'.tmp';
$json = (string) json_encode([
'exported_at' => now()->toIso8601String(),
'entries' => $rows,
], JSON_PRETTY_PRINT);
$exportedAt = now()->toIso8601String();
$key = $this->keyBytes();
$hmac = $key !== null ? hash_init('sha256', HASH_HMAC, $this->derivedKey($key, self::TOMBSTONE_EXPORT_CONTEXT)) : null;
$stream = fopen('php://temp', 'w+b');
if (! $this->disk()->put($temp, $json)) {
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
if ($stream === false) {
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht vorbereitet werden.');
}
$count = 0;
try {
fwrite($stream, '{"exported_at":'.json_encode($exportedAt).',"key_id":'.json_encode($this->keyId()).',"entries":[');
if ($hmac !== null) {
hash_update($hmac, $exportedAt."\n");
}
ArchiveEntry::query()->whereNotNull('deleted_at')->select(['id', 'payload_path', 'deleted_at'])
->lazyById(1000)
->each(function (ArchiveEntry $entry) use ($stream, $hmac, &$count): void {
$line = $this->tombstoneLine([
'id' => $entry->getKey(),
'payload_path' => $entry->payload_path,
'deleted_at' => $entry->deleted_at?->toIso8601String(),
]);
fwrite($stream, ($count > 0 ? ',' : '')."\n".$line);
if ($hmac !== null) {
hash_update($hmac, $line."\n");
}
$count++;
});
fwrite($stream, "\n],\"hmac\":".json_encode($hmac !== null ? hash_final($hmac) : null)."}\n");
rewind($stream);
if (! $this->disk()->put($temp, $stream)) {
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht geschrieben werden.');
}
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
@ -315,23 +417,34 @@ class EvidenceArchive
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
}
return count($rows);
return $count;
}
/**
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
* Tombstone-Stand gelöscht war, erneut entfernen (Datei und Zeile).
* Zusätzlich jede Tombstone-Zeile der Datenbank, deren Payload wieder
* auf der Disk liegt — deckt den Fall „Disk alt, Datenbank aktuell"
* ab (5.1 c).
* Tombstone-Stand gelöscht war, erneut entfernen (Datei, Zeile und die
* Kopien in der Anwendung, A-06). Zusätzlich jede Tombstone-Zeile der
* Datenbank, deren Payload wieder auf der Disk liegt — deckt den Fall
* „Disk alt, Datenbank aktuell" ab (5.1 c). Ein Export mit ungültiger
* Signatur wird verworfen (A-15). Der Orphan-Sweep wird ausgesetzt,
* bis die Wiederregistrierung gelaufen ist (A-03).
*
* @return array{re_tombstoned: int, files_removed: int, failed: int}
* @return array{re_tombstoned: int, files_removed: int, failed: int, conflicts: int, export_signature: string, sweep_paused: bool}
*/
public function reconcileAfterRestore(): array
public function reconcileAfterRestore(bool $acceptUnsigned = false): array
{
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0];
$raw = $this->disk()->get((string) config('archive.tombstone_export_path'));
$rows = is_string($raw) && $raw !== '' ? (array) (json_decode($raw, true)['entries'] ?? []) : [];
$result = ['re_tombstoned' => 0, 'files_removed' => 0, 'failed' => 0, 'conflicts' => 0, 'export_signature' => 'missing', 'sweep_paused' => false];
$this->pauseSweep();
$result['sweep_paused'] = true;
[$rows, $result['export_signature']] = $this->readTombstoneExport($acceptUnsigned);
if (in_array($result['export_signature'], ['invalid', 'unsigned_refused'], true)) {
$result['failed']++;
Log::error('Beweisarchiv: Tombstone-Export nicht vertrauenswürdig ('.$result['export_signature'].') — verworfen, nur Datenbank-Tombstones abgeglichen.');
}
$handled = [];
foreach ($rows as $row) {
@ -342,15 +455,37 @@ class EvidenceArchive
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
}
$entry = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
// Identität ist der Payload-Pfad (UUID), nicht die ID: Nach einem
// Restore oder einer Wiederregistrierung kann dieselbe ID einen
// anderen Eintrag tragen (Umsetzungsaudit 02.10.2026, O04).
$entry = $path !== null ? ArchiveEntry::query()->where('payload_path', $path)->first() : null;
if ($entry !== null && ! $entry->isTombstoned()) {
$entry->forceFill(['deleted_at' => now()])->save();
if ($entry === null) {
$sameId = ArchiveEntry::query()->find((int) ($row['id'] ?? 0));
if ($sameId !== null && $sameId->payload_path !== $path) {
$result['conflicts']++;
Log::warning('Beweisarchiv: Tombstone-Export und Datenbank widersprechen sich — Eintrag nicht angefasst.', [
'export_id' => (int) ($row['id'] ?? 0),
'export_path' => $path,
'db_path' => $sameId->payload_path,
]);
}
continue;
}
if (! $entry->isTombstoned()) {
DB::transaction(function () use ($entry): void {
$locked = $this->lockedEntry($entry);
$this->purgeApplicationCopies($locked);
$locked->forceFill(['deleted_at' => now()])->save();
});
$result['re_tombstoned']++;
}
}
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')->orderBy('id')
ArchiveEntry::query()->whereNotNull('deleted_at')->whereNotNull('payload_path')
->chunkById(500, function ($entries) use (&$result, &$handled): void {
foreach ($entries as $entry) {
$path = (string) $entry->payload_path;
@ -365,36 +500,143 @@ class EvidenceArchive
});
if ($result['failed'] > 0) {
Log::error('Beweisarchiv: Restore-Abgleich konnte Payloads nicht entfernen.', ['failed' => $result['failed']]);
Log::error('Beweisarchiv: Restore-Abgleich unvollständig.', ['failed' => $result['failed'], 'export_signature' => $result['export_signature']]);
}
return $result;
}
/**
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback,
* liegengebliebene .tmp) nach Karenzzeit entfernen (N11).
* Payloads ohne Archivzeile (abgebrochene Ablage, äußerer Rollback)
* nach Karenzzeit in die Quarantäne verschieben, statt sie zu löschen
* (A-03): nach einem DB-Restore fehlen echten Einträgen die Zeilen.
* Liegengebliebene `.tmp` sind nie ein vollständiger Eintrag und werden
* gelöscht. Quarantäne-Tage älter als `archive.quarantine_days` fallen.
* Ausgesetzt (Config oder Schalter) passiert nichts. Verzeichnisweise
* statt einer Gesamtliste (OPT-6).
*
* @return array{scanned: int, orphans: int, removed: int}
* @return array{paused: bool, scanned: int, orphans: int, quarantined: int, removed: int, expired: int}
*/
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
{
$result = ['scanned' => 0, 'orphans' => 0, 'removed' => 0];
$result = ['paused' => false, 'scanned' => 0, 'orphans' => 0, 'quarantined' => 0, 'removed' => 0, 'expired' => 0];
if ($this->isSweepPaused()) {
$result['paused'] = true;
return $result;
}
$threshold = now()->subHours($olderThanHours)->getTimestamp();
$quarantineDay = 'quarantine/'.now()->toDateString();
foreach (array_chunk($this->disk()->allFiles('entries'), 500) as $chunk) {
$result['scanned'] += count($chunk);
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
foreach ($this->payloadDirectories('entries') as $directory) {
foreach (array_chunk($this->disk()->files($directory), 500) as $chunk) {
$result['scanned'] += count($chunk);
$known = ArchiveEntry::query()->whereIn('payload_path', $chunk)->pluck('payload_path')->all();
foreach (array_diff($chunk, $known) as $file) {
if ($this->disk()->lastModified($file) > $threshold) {
foreach (array_diff($chunk, $known) as $file) {
if ($this->disk()->lastModified($file) > $threshold) {
continue;
}
$result['orphans']++;
if ($dryRun) {
continue;
}
if (str_ends_with($file, '.tmp')) {
if (FileDeletion::delete($this->disk(), $file)) {
$result['removed']++;
}
continue;
}
if ($this->disk()->move($file, $quarantineDay.'/'.$file)) {
$result['quarantined']++;
}
}
}
}
$result['expired'] = $this->expireQuarantine($dryRun);
return $result;
}
/**
* Zeilen für Payloads ohne Zeile aus deren Manifest wiederherstellen
* (nach einem DB-Restore, A-03): aus `entries/` und aus der Quarantäne
* (dann zurück an den ursprünglichen Pfad). Payloads, die laut
* Tombstone-Export gelöscht sind, bleiben außen vor. Legal Hold und
* Kennzahlen kennt das Manifest nicht — sie müssen geprüft werden.
*
* Ohne vertrauenswürdigen Export lässt sich nicht unterscheiden, ob eine
* Payload ohne Zeile ein verlorener Eintrag oder ein absichtlich
* gelöschter ist. Deshalb bricht der Lauf ab bei ungültiger Signatur,
* bei unsigniertem Export ohne `$acceptUnsigned` und bei fehlendem Export
* ohne `$withoutExport` (Umsetzungsaudit 02.10.2026, O05).
*
* @return array{candidates: int, registered: int, skipped_tombstoned: int, failed: int, export_signature: string, aborted: bool}
*/
public function reregister(bool $dryRun = false, bool $acceptUnsigned = false, bool $withoutExport = false): array
{
$result = ['candidates' => 0, 'registered' => 0, 'skipped_tombstoned' => 0, 'failed' => 0, 'export_signature' => 'missing', 'aborted' => false];
[$rows, $result['export_signature']] = $this->readTombstoneExport($acceptUnsigned);
if (in_array($result['export_signature'], ['invalid', 'unsigned_refused'], true)
|| ($result['export_signature'] === 'missing' && ! $withoutExport)) {
$result['aborted'] = true;
Log::error('Beweisarchiv: Wiederregistrierung abgebrochen — Tombstone-Export '.$result['export_signature'].'.');
return $result;
}
$tombstoned = array_fill_keys(array_filter(array_map(fn (array $row): ?string => is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null, $rows)), true);
$sources = [];
foreach ($this->payloadDirectories('entries') as $directory) {
foreach ($this->disk()->files($directory) as $file) {
$sources[] = [$file, $file];
}
}
foreach ($this->disk()->directories('quarantine') as $day) {
foreach ($this->payloadDirectories($day.'/entries') as $directory) {
foreach ($this->disk()->files($directory) as $file) {
$sources[] = [$file, Str::after($file, $day.'/')];
}
}
}
foreach (array_chunk($sources, 500) as $chunk) {
$known = ArchiveEntry::query()->whereIn('payload_path', array_column($chunk, 1))->pluck('payload_path')->flip()->all();
foreach ($chunk as [$source, $target]) {
if (! str_ends_with($target, '.bin') || isset($known[$target])) {
continue;
}
$result['orphans']++;
if (isset($tombstoned[$target])) {
$result['skipped_tombstoned']++;
if (! $dryRun && FileDeletion::delete($this->disk(), $file)) {
$result['removed']++;
continue;
}
$result['candidates']++;
if ($dryRun) {
continue;
}
try {
$this->reregisterPayload($source, $target);
$result['registered']++;
} catch (Throwable $exception) {
$result['failed']++;
Log::error('Beweisarchiv: Wiederregistrierung fehlgeschlagen.', ['path' => $target, 'error' => $exception->getMessage()]);
}
}
}
@ -402,6 +644,21 @@ class EvidenceArchive
return $result;
}
public function isSweepPaused(): bool
{
return (bool) config('archive.sweep_paused') || (bool) AppSetting::getValueFresh(self::SWEEP_PAUSED_SETTING, false);
}
public function pauseSweep(): void
{
AppSetting::setValue(self::SWEEP_PAUSED_SETTING, true);
}
public function resumeSweep(): void
{
AppSetting::setValue(self::SWEEP_PAUSED_SETTING, false);
}
/**
* Unter Zeilensperre erneut prüfen, dann Datei bestätigt löschen, dann
* Tombstone. Liefert false, wenn der Eintrag inzwischen nicht mehr
@ -484,6 +741,8 @@ class EvidenceArchive
ArchiveAccessLog::query()->create([
'entry_id' => $entry->getKey(),
'admin_id' => $admin->getKey(),
'admin_name' => Str::limit((string) $admin->name, 250, ''),
'admin_email' => Str::limit((string) $admin->email, 250, ''),
'action' => $action,
'purpose' => Str::limit($purpose, 490, ''),
'accessed_at' => now(),
@ -502,7 +761,17 @@ class EvidenceArchive
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
}
$zip = (string) $this->encrypter()->decrypt($cipher, false);
$keys = $this->keyring();
if ($entry->key_id !== null) {
if (! isset($keys[$entry->key_id])) {
throw new RuntimeException('Beweisarchiv: Schlüssel '.$entry->key_id.' dieses Eintrags ist nicht konfiguriert (ARCHIVE_KEY/ARCHIVE_PREVIOUS_KEYS).');
}
$keys = [$entry->key_id => $keys[$entry->key_id]];
}
[$zip] = $this->decryptWithAny($cipher, $keys);
if (! hash_equals((string) $entry->payload_hash, hash('sha256', $zip))) {
throw new RuntimeException('Beweisarchiv: Hash des Payloads stimmt nicht mit dem Eintrag überein.');
@ -511,6 +780,234 @@ class EvidenceArchive
return $zip;
}
/**
* @param array<string, string> $keys Schlüssel-ID → Schlüssel
* @return array{0: string, 1: string} Klartext und Schlüssel-ID
*/
private function decryptWithAny(string $cipher, array $keys): array
{
$cipherName = (string) config('archive.cipher', 'AES-256-CBC');
foreach ($keys as $id => $key) {
try {
return [(string) (new Encrypter($key, $cipherName))->decrypt($cipher, false), $id];
} catch (DecryptException) {
continue;
}
}
throw new RuntimeException('Beweisarchiv: Payload lässt sich mit keinem bekannten Schlüssel entschlüsseln.');
}
/**
* @return array<string, mixed>
*/
private function manifestFromZip(string $bytes): array
{
$temp = $this->tempFile($bytes);
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) : [];
}
/**
* Zeile aus dem Manifest eines verwaisten Payloads anlegen, Datei ggf.
* aus der Quarantäne zurück an ihren Pfad (wie record(): Zeile und
* Verschiebung in einer Transaktion).
*/
private function reregisterPayload(string $source, string $target): void
{
$cipher = $this->disk()->get($source);
if (! is_string($cipher) || $cipher === '') {
throw new RuntimeException('Beweisarchiv: Payload ist leer.');
}
[$zip, $keyId] = $this->decryptWithAny($cipher, $this->keyring());
$manifest = $this->manifestFromZip($zip);
$event = ArchiveEvent::tryFrom((string) ($manifest['event'] ?? ''));
$reason = ArchiveReasonCategory::tryFrom((string) ($manifest['reason_category'] ?? ''));
if ($event === null || $reason === null || ! isset($manifest['subject_type'], $manifest['subject_id'])) {
throw new RuntimeException('Beweisarchiv: Manifest unvollständig.');
}
$recordedAt = isset($manifest['recorded_at']) ? Carbon::parse((string) $manifest['recorded_at']) : Carbon::createFromTimestamp($this->disk()->lastModified($source));
DB::transaction(function () use ($manifest, $event, $reason, $zip, $keyId, $recordedAt, $source, $target): void {
ArchiveEntry::query()->create([
'subject_type' => (string) $manifest['subject_type'],
'subject_id' => (int) $manifest['subject_id'],
'press_release_id' => isset($manifest['press_release_id']) ? (int) $manifest['press_release_id'] : null,
'legal_request_id' => isset($manifest['legal_request_id']) ? (int) $manifest['legal_request_id'] : null,
'event' => $event,
'actor_type' => (string) ($manifest['actor_type'] ?? 'system'),
'actor_id' => isset($manifest['actor_id']) ? (int) $manifest['actor_id'] : null,
'reason_category' => $reason,
'summary' => ['reregistered_at' => now()->toIso8601String()],
'payload_path' => $target,
'payload_hash' => hash('sha256', $zip),
'payload_bytes' => strlen($zip),
'key_id' => $keyId,
'retain_until' => ArchiveEntry::retentionEndFor($recordedAt),
'created_at' => $recordedAt,
]);
if ($source !== $target && (! $this->disk()->move($source, $target) || ! $this->disk()->exists($target))) {
throw new RuntimeException('Beweisarchiv: Payload konnte nicht aus der Quarantäne zurückgeholt werden.');
}
});
}
/**
* Tombstone-Export lesen und die Signatur prüfen.
*
* @return array{0: list<array<string, mixed>>, 1: string} Zeilen und Status (valid, unsigned, invalid, missing)
*/
private function readTombstoneExport(bool $acceptUnsigned = false): array
{
$path = (string) config('archive.tombstone_export_path');
$raw = $this->disk()->exists($path) ? $this->disk()->get($path) : null;
if (! is_string($raw) || $raw === '') {
return [[], 'missing'];
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return [[], 'invalid'];
}
$rows = array_values(array_filter((array) ($data['entries'] ?? []), 'is_array'));
if (! isset($data['hmac']) || ! is_string($data['hmac'])) {
// Exporte vor dem 29.09.2026 (oder ohne Schlüssel) sind
// unsigniert. Das Dateiformat beweist das aber nicht — ein
// entferntes `hmac` sähe genauso aus. Deshalb nur auf
// ausdrückliche Anweisung verwenden (Umsetzungsaudit 02.10.2026, O05).
return $acceptUnsigned ? [$rows, 'unsigned'] : [[], 'unsigned_refused'];
}
$keys = $this->keyring();
if (isset($data['key_id']) && is_string($data['key_id']) && isset($keys[$data['key_id']])) {
$keys = [$data['key_id'] => $keys[$data['key_id']]];
}
foreach ($keys as $key) {
$hmac = hash_init('sha256', HASH_HMAC, $this->derivedKey($key, self::TOMBSTONE_EXPORT_CONTEXT));
hash_update($hmac, (string) ($data['exported_at'] ?? '')."\n");
foreach ($rows as $row) {
hash_update($hmac, $this->tombstoneLine($row)."\n");
}
if (hash_equals(hash_final($hmac), $data['hmac'])) {
return [$rows, 'valid'];
}
}
return [[], 'invalid'];
}
/**
* @param array<string, mixed> $row
*/
private function tombstoneLine(array $row): string
{
return (string) json_encode([
'id' => (int) ($row['id'] ?? 0),
'payload_path' => $row['payload_path'] ?? null,
'deleted_at' => $row['deleted_at'] ?? null,
]);
}
/**
* Unterverzeichnisse (Jahre) plus das Verzeichnis selbst.
*
* @return list<string>
*/
private function payloadDirectories(string $root): array
{
return array_values(array_merge([$root], $this->disk()->directories($root)));
}
/**
* Quarantäne-Tage nach Ablauf der Frist entfernen.
*/
private function expireQuarantine(bool $dryRun): int
{
$cutoff = today()->subDays(max(1, (int) config('archive.quarantine_days', 30)));
$expired = 0;
foreach ($this->disk()->directories('quarantine') as $day) {
$date = rescue(fn () => Carbon::createFromFormat('Y-m-d', basename($day))?->startOfDay(), null, false);
if (! $date instanceof Carbon || $date->gte($cutoff)) {
continue;
}
$count = count($this->disk()->allFiles($day));
if ($dryRun || $this->disk()->deleteDirectory($day)) {
$expired += $count;
}
}
return $expired;
}
/**
* Aktueller Schlüssel zuerst, dann ARCHIVE_PREVIOUS_KEYS.
*
* @return array<string, string> Schlüssel-ID → Schlüssel
*/
private function keyring(): array
{
$keys = [];
$current = $this->keyBytes();
if ($current !== null) {
$keys[$this->idForKey($current)] = $current;
}
$previous = config('archive.previous_keys', []);
$previous = is_string($previous) ? explode(',', $previous) : (array) $previous;
foreach ($previous as $candidate) {
$bytes = $this->decodeKey(trim((string) $candidate));
if ($bytes !== null) {
$keys[$this->idForKey($bytes)] ??= $bytes;
}
}
return $keys;
}
private function idForKey(string $key): string
{
return substr(hash('sha256', 'pk-archive-key-id|'.$key), 0, 16);
}
private function derivedKey(string $key, string $context): string
{
return hash_hkdf('sha256', $key, 32, $context);
}
/**
* @param array<string, mixed> $manifest
* @param array<string, string> $files
@ -565,8 +1062,11 @@ class EvidenceArchive
private function keyBytes(): ?string
{
$key = (string) config('archive.key');
return $this->decodeKey((string) config('archive.key'));
}
private function decodeKey(string $key): ?string
{
if ($key === '') {
return null;
}

View file

@ -7,43 +7,264 @@ use App\Enums\ArchiveReasonCategory;
use App\Enums\LegalRequestType;
use App\Enums\PressReleaseStatus;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\ImageDeclarationCorrection;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Nachholer für Archiveinträge (Umsetzungsaudit 25.09.2026, N11): Der
* Geschäftsvorgang setzt `press_releases.archive_pending_at` in seiner
* Transaktion und löscht den Marker erst nach erfolgreichem Eintrag.
* Bleibt er stehen, holt retry() alles nach, was seit dem Marker fehlt:
* Nachholer für Archiveinträge (N11, Umsetzungsaudit 29.09.2026 A-01/A-02/
* A-04/A-09/A-10). Arbeitet je Auftrag (ArchivePendingJob) und je
* Ereignistyp:
*
* - `pm_depublished`, wenn die Meldung nicht mehr veröffentlicht ist;
* - `gdpr_change` je Redaction-Snapshot ohne Eintrag (Vorher-Stand aus dem
* Snapshot, Nachher-Stand aus der Meldung);
* - `image_removed` je weich gelöschtem v2-Bild ohne Eintrag.
* - `pm_depublished`: aus dem am Auftrag festgehaltenen Stand zum Zeitpunkt
* der Depublikation — nie aus dem aktuellen Text;
* - `gdpr_change`: Vorher- und Nachher-Stand aus dem Auftrag;
* - `image_removed`/`image_replaced`: weich gelöschtes v2-Bild ins Archiv;
* - `label_corrected`: Korrekturzeile ins Archiv (Auftrags-API für den
* Korrekturweg, siehe ArchivePendingQueue);
* - `image_purge`: gescheiterte Sofortlöschung eines nie veröffentlichten
* Bildes wiederholen (kein Archiveintrag);
* - `legacy_marker`: Marker ohne Auftrag aus der Zeit vor den Aufträgen,
* einmalig mit der bisherigen Heuristik.
*
* Ein Auftrag fällt nur zusammen mit seinem Eintrag (eine Transaktion).
* Existiert der Eintrag schon, gilt der Auftrag als erledigt; wird nichts
* archiviert (Schlüssel fehlt), bleibt er offen und zählt als übersprungen.
*/
class PendingArchiveRetry
{
private const NOTE = 'Nachgeholt durch archive:retry-pending';
public function __construct(
private readonly PressReleaseArchiver $archiver,
private readonly PressReleaseImageRemover $remover,
private readonly ArchivePendingQueue $queue,
) {}
/**
* @return array{recorded: int, failed: int}
* @return array{recorded: int, skipped: int, failed: int}
*/
public function retry(PressRelease $pressRelease): array
public function retry(ArchivePendingJob $job): array
{
$result = ['recorded' => 0, 'failed' => 0];
$since = $pressRelease->archive_pending_at;
$alreadyCompleted = false;
if ($since === null) {
return $result;
try {
$recorded = DB::transaction(function () use ($job, &$alreadyCompleted): ?int {
// Beanspruchen wie der Direktweg (O07); mit der frisch
// gelesenen Zeile weiterarbeiten.
$claimed = $this->queue->claim($job);
if ($claimed === null) {
$alreadyCompleted = true;
return null;
}
$recorded = $this->handle($claimed);
if ($recorded !== null) {
$this->queue->complete($claimed);
}
return $recorded;
});
} catch (Throwable $exception) {
$this->queue->recordFailure($job, $exception->getMessage());
Log::error('Beweisarchiv: Nachholen eines Auftrags fehlgeschlagen.', [
'job_id' => $job->getKey(),
'event' => $job->event,
'press_release_id' => $job->press_release_id,
'error' => $exception->getMessage(),
]);
return ['recorded' => 0, 'skipped' => 0, 'failed' => 1];
}
if ($alreadyCompleted) {
return ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
}
if ($recorded === null) {
$this->queue->recordFailure($job, 'Eintrag wurde nicht archiviert (ARCHIVE_KEY fehlt oder ist ungültig).');
return ['recorded' => 0, 'skipped' => 1, 'failed' => 0];
}
return ['recorded' => $recorded, 'skipped' => 0, 'failed' => 0];
}
/**
* Anzahl geschriebener Einträge; null = nicht archiviert (Auftrag bleibt).
*/
private function handle(ArchivePendingJob $job): ?int
{
return match ($job->event) {
ArchiveEvent::PressReleaseDepublished->value => $this->counted($this->depublished($job)),
ArchiveEvent::GdprChange->value => $this->counted($this->gdprChange($job)),
ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value => $this->imageRemoved($job),
ArchiveEvent::LabelCorrected->value => $this->labelCorrected($job),
ArchivePendingJob::EVENT_IMAGE_PURGE => $this->imagePurge($job),
ArchivePendingJob::EVENT_LEGACY_MARKER => $this->legacyMarker($job),
default => throw new RuntimeException('Beweisarchiv: unbekannte Auftragsart '.$job->event.'.'),
};
}
private function depublished(ArchivePendingJob $job): ?ArchiveEntry
{
$pressRelease = $this->pressRelease($job);
$state = $job->payload['state'] ?? null;
if (! is_array($state) || $state === []) {
throw new RuntimeException('Beweisarchiv: Auftrag ohne festgehaltenen Stand der Depublikation.');
}
return $this->archiver->recordDepublished(
$pressRelease,
$state,
$job->reason_category ?? ArchiveReasonCategory::OwnDecision,
$this->actor($job),
$this->note($job),
$this->legalRequest($job),
);
}
private function gdprChange(ArchivePendingJob $job): ?ArchiveEntry
{
$payload = (array) $job->payload;
$snapshotId = $job->subject_type === (new PressReleaseRedactionSnapshot)->getMorphClass() ? $job->subject_id : null;
return $this->archiver->gdprChange(
$this->pressRelease($job),
(array) ($payload['before'] ?? []),
(array) ($payload['after'] ?? []),
$this->legalRequest($job),
$this->actor($job),
$job->reason_category ?? ArchiveReasonCategory::Gdpr,
$this->note($job),
$snapshotId,
);
}
private function imageRemoved(ArchivePendingJob $job): ?int
{
$image = PressReleaseImage::withTrashed()->find($job->subject_id);
if ($image === null || $this->imageEntryExists($image)) {
return 0;
}
return $this->counted($this->remover->archiveTrashed(
$image,
$this->actor($job),
ArchiveEvent::from($job->event),
$job->reason_category ?? ArchiveReasonCategory::OwnDecision,
$this->legalRequest($job),
$this->note($job),
));
}
private function labelCorrected(ArchivePendingJob $job): ?int
{
$correction = ImageDeclarationCorrection::query()->find($job->subject_id);
$image = $correction !== null ? PressReleaseImage::withTrashed()->find($correction->press_release_image_id) : null;
if ($correction === null || $image === null) {
return 0;
}
$exists = ArchiveEntry::query()
->where('subject_type', $image->getMorphClass())
->where('subject_id', $image->getKey())
->where('event', ArchiveEvent::LabelCorrected->value)
->get(['id', 'summary'])
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['correction_id'] ?? 0) === $correction->getKey());
if ($exists) {
return 0;
}
return $this->counted($this->archiver->labelCorrected($image, $correction, $this->actor($job)));
}
/**
* Gescheiterte Sofortlöschung wiederholen (A-10). Der Remover arbeitet
* nur auf nicht gelöschten Zeilen; die weich gelöschte Zeile wird dafür
* innerhalb der Transaktion zurückgeholt — scheitert die Löschung
* erneut, rollt das mit zurück und das Bild bleibt ausgeblendet.
*/
private function imagePurge(ArchivePendingJob $job): ?int
{
$image = PressReleaseImage::withTrashed()->find($job->subject_id);
if ($image === null) {
return 0;
}
// Inzwischen veröffentlicht gewesen: nie sofort löschen, archivieren.
if ($image->wasEverPublished() && ! $image->isLegacyRules()) {
if ($this->imageEntryExists($image)) {
return 0;
}
return $this->counted($this->remover->archiveTrashed($image, $this->actor($job), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::OwnDecision, null, $this->note($job)));
}
if ($image->trashed()) {
$image->restoreQuietly();
}
$this->remover->remove($image);
if (PressReleaseImage::withTrashed()->whereKey($image->getKey())->exists()) {
throw new RuntimeException('Beweisarchiv: Sofortlöschung von Bild #'.$image->getKey().' erneut nicht abgeschlossen.');
}
return 0;
}
/**
* Marker aus der Zeit vor den Aufträgen: bisherige Heuristik, einmalig.
* Vorher-Stände kommen aus den Redaction-Snapshots, die
* `snapshots:purge` bei offenem Auftrag stehen lässt (A-13).
*/
private function legacyMarker(ArchivePendingJob $job): ?int
{
$pressRelease = $this->pressRelease($job);
$since = $job->created_at ?? now();
$recorded = 0;
if ($pressRelease->status !== PressReleaseStatus::Published && $pressRelease->published_ever_at !== null) {
$exists = ArchiveEntry::query()
->where('press_release_id', $pressRelease->getKey())
->where('event', ArchiveEvent::PressReleaseDepublished->value)
->where('created_at', '>=', $since)
->exists();
if (! $exists) {
$entry = $this->archiver->pressReleaseDepublished(
$pressRelease,
$pressRelease->depublished_at !== null ? ArchiveReasonCategory::CustomerRequest : ArchiveReasonCategory::OwnDecision,
null,
self::NOTE.' (Altmarker, Stand beim Nachholen)',
);
if ($entry === null) {
return null;
}
$recorded++;
}
}
$snapshots = PressReleaseRedactionSnapshot::query()
@ -53,105 +274,57 @@ class PendingArchiveRetry
->orderBy('id')
->get();
$this->attempt($result, fn () => $this->retryDepublished($pressRelease, $since));
foreach ($snapshots as $snapshot) {
$this->attempt($result, fn () => $this->retryGdprChange($pressRelease, $snapshot));
$exists = ArchiveEntry::query()
->where('press_release_id', $pressRelease->getKey())
->where('event', ArchiveEvent::GdprChange->value)
->get(['id', 'summary'])
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['redaction_snapshot_id'] ?? 0) === $snapshot->getKey());
if ($exists) {
continue;
}
$before = (array) ($snapshot->encrypted_before['fields'] ?? []);
$legalRequest = $snapshot->legalRequest;
$entry = $this->archiver->gdprChange(
$pressRelease,
$before,
$pressRelease->only(array_keys($before)),
$legalRequest,
null,
$legalRequest?->type === LegalRequestType::Correction ? ArchiveReasonCategory::Correction : ArchiveReasonCategory::Gdpr,
self::NOTE.' (Altmarker)',
$snapshot->getKey(),
);
if ($entry === null) {
return null;
}
$recorded++;
}
$latestRequest = $snapshots->last()?->legalRequest;
foreach ($this->trashedImagesWithoutEntry($pressRelease) as $image) {
$this->attempt($result, fn () => $this->remover->archiveTrashed(
$entry = $this->remover->archiveTrashed(
$image,
null,
ArchiveEvent::ImageRemoved,
$latestRequest !== null ? ArchiveReasonCategory::Gdpr : ArchiveReasonCategory::OwnDecision,
$latestRequest,
'Nachgeholt durch archive:retry-pending',
));
self::NOTE.' (Altmarker)',
);
if ($entry === null) {
return null;
}
$recorded++;
}
if ($result['failed'] === 0) {
$pressRelease->forceFill(['archive_pending_at' => null])->save();
}
return $result;
}
/**
* @param array{recorded: int, failed: int} $result
* @param callable(): ?ArchiveEntry $step
*/
private function attempt(array &$result, callable $step): void
{
try {
$entry = $step();
} catch (Throwable $exception) {
$result['failed']++;
Log::error('Beweisarchiv: Nachholen eines Eintrags fehlgeschlagen.', ['error' => $exception->getMessage()]);
return;
}
if ($entry === null) {
return;
}
$result['recorded']++;
}
private function retryDepublished(PressRelease $pressRelease, \DateTimeInterface $since): ?ArchiveEntry
{
if ($pressRelease->status === PressReleaseStatus::Published || $pressRelease->published_ever_at === null) {
return null;
}
$exists = ArchiveEntry::query()
->where('press_release_id', $pressRelease->getKey())
->where('event', ArchiveEvent::PressReleaseDepublished->value)
->where('created_at', '>=', $since)
->exists();
if ($exists) {
return null;
}
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]);
return $this->archiver->pressReleaseDepublished(
$pressRelease,
$pressRelease->depublished_at !== null ? ArchiveReasonCategory::CustomerRequest : ArchiveReasonCategory::OwnDecision,
null,
'Nachgeholt durch archive:retry-pending',
);
}
private function retryGdprChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot): ?ArchiveEntry
{
$exists = ArchiveEntry::query()
->where('press_release_id', $pressRelease->getKey())
->where('event', ArchiveEvent::GdprChange->value)
->get(['id', 'summary'])
->contains(fn (ArchiveEntry $entry): bool => (int) ($entry->summary['redaction_snapshot_id'] ?? 0) === $snapshot->getKey());
if ($exists) {
return null;
}
$before = (array) ($snapshot->encrypted_before['fields'] ?? []);
$legalRequest = $snapshot->legalRequest;
return $this->archiver->gdprChange(
$pressRelease,
$before,
$pressRelease->only(array_keys($before)),
$legalRequest,
null,
$legalRequest?->type === LegalRequestType::Correction ? ArchiveReasonCategory::Correction : ArchiveReasonCategory::Gdpr,
'Nachgeholt durch archive:retry-pending',
$snapshot->getKey(),
);
return $recorded;
}
/**
@ -159,18 +332,55 @@ class PendingArchiveRetry
*/
private function trashedImagesWithoutEntry(PressRelease $pressRelease): Collection
{
$morph = (new PressReleaseImage)->getMorphClass();
return PressReleaseImage::withTrashed()
->where('press_release_id', $pressRelease->getKey())
->whereNotNull('deleted_at')
->whereNotNull('published_ever_at')
->whereNotNull('rules_version')
->get()
->reject(fn (PressReleaseImage $image): bool => ArchiveEntry::query()
->where('subject_type', $morph)
->where('subject_id', $image->getKey())
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
->exists());
->reject(fn (PressReleaseImage $image): bool => $this->imageEntryExists($image));
}
private function imageEntryExists(PressReleaseImage $image): bool
{
return ArchiveEntry::query()
->where('subject_type', $image->getMorphClass())
->where('subject_id', $image->getKey())
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
->exists();
}
private function pressRelease(ArchivePendingJob $job): PressRelease
{
$pressRelease = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->withTrashed()
->find($job->press_release_id);
if (! $pressRelease instanceof PressRelease) {
throw new RuntimeException('Beweisarchiv: Meldung #'.$job->press_release_id.' des Auftrags existiert nicht mehr.');
}
return $pressRelease;
}
private function legalRequest(ArchivePendingJob $job): ?LegalRequest
{
return $job->legal_request_id !== null ? LegalRequest::query()->find($job->legal_request_id) : null;
}
private function actor(ArchivePendingJob $job): ?User
{
return $job->actor_id !== null ? User::query()->find($job->actor_id) : null;
}
private function note(ArchivePendingJob $job): string
{
return $job->note !== null && $job->note !== '' ? $job->note.' — '.self::NOTE : self::NOTE;
}
private function counted(?ArchiveEntry $entry): ?int
{
return $entry !== null ? 1 : null;
}
}

View file

@ -11,8 +11,10 @@ use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\PressRelease\Images\PublishedImageSnapshot;
use App\Support\ImageCredit;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* Baut die Archiv-Payloads für die Ereignisse aus Decision-Update 4.1 und
@ -40,13 +42,22 @@ class PressReleaseArchiver
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);
$sidecar = PublishedImageSnapshot::sidecarPath($image->published_snapshot_path);
if (is_string($sidecar) && $disk->exists($sidecar)) {
$files['published.json'] = (string) $disk->get($sidecar);
}
}
// Jede weitere Fassung des Veröffentlichungssnapshots (N09): OG mit
// eingebranntem Nachweis sowie Cover und Sidecar früherer
// Korrekturstände. Die aktuelle Fassung liegt schon als published.*.
$current = array_filter([$image->published_snapshot_path, PublishedImageSnapshot::sidecarPath($image->published_snapshot_path)]);
foreach (array_diff(PublishedImageSnapshot::versionPaths($image), $current) as $versionPath) {
$files['published-history/'.basename($versionPath)] = (string) $disk->get($versionPath);
}
$pressRelease = $image->pressRelease;
return $this->archive->record(
@ -61,7 +72,7 @@ class PressReleaseArchiver
'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', 'precheck', 'precheck_outcome', 'precheck_review_required_at',
'published_ever_at', 'withheld_at', 'precheck_withheld_at', 'precheck', 'precheck_outcome', 'precheck_review_required_at',
'precheck_reviewed_at', 'precheck_reviewed_by', 'precheck_review_note', 'created_at', 'deleted_at',
]),
'credit_line' => $pressRelease ? ImageCredit::for($image, $pressRelease->company)->line() : null,
@ -80,16 +91,38 @@ class PressReleaseArchiver
/**
* Meldung aus dem öffentlichen Angebot genommen: letzter veröffentlichter
* Stand mit Text, Bildern (Snapshots), Kontaktfeldern, Einreicher, Zeiten.
* Blockierender Direktweg (deleteFromAdmin); alle anderen Wege halten den
* Stand zuerst im Auftrag fest (depublicationState) und archivieren über
* recordDepublished().
*/
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null): ?ArchiveEntry
public function pressReleaseDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry
{
$pressRelease->loadMissing(['company', 'user', 'contacts', 'images']);
return $this->recordDepublished($pressRelease, $this->depublicationState($pressRelease), $reason, $actor, $note, $legalRequest);
}
/**
* Stand der Meldung zum Zeitpunkt der Depublikation (Umsetzungsaudit
* 29.09.2026, A-04): wird in der fachlichen Transaktion am Auftrag
* gespeichert, damit ein nachgeholter Eintrag nicht den Stand nach einer
* späteren Bearbeitung oder Schwärzung abbildet. Firma und Kontakte ohne
* PortalScope — ein Request mit anderem Portal-Kontext darf sie nicht
* aus dem Nachweis filtern. Nur JSON-taugliche Werte.
*
* @return array<string, mixed>
*/
public function depublicationState(PressRelease $pressRelease): array
{
$pressRelease->load([
'company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class),
'contacts' => fn ($q) => $q->withoutGlobalScope(PortalScope::class),
'user',
'images',
]);
$files = [];
$images = [];
foreach ($pressRelease->images as $image) {
$entry = [
$images[] = [
'id' => $image->getKey(),
'credit_line' => ImageCredit::for($image, $pressRelease->company)->line(),
'ai_declaration' => $image->ai_declaration?->value,
@ -97,19 +130,60 @@ class PressReleaseArchiver
'original_sha256' => $image->original_sha256,
'published_snapshot_sha256' => $image->published_snapshot_sha256,
'rules_version' => $image->rules_version,
'snapshot_disk' => $image->original_disk ?? (string) config('images.originals_disk'),
'snapshot_path' => $image->published_snapshot_path,
];
}
if ($image->published_snapshot_path !== null) {
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
return [
'captured_at' => now()->toIso8601String(),
'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']))->values()->all(),
'images' => $images,
];
}
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;
/**
* Archiveintrag `pm_depublished` aus einem festgehaltenen Stand. Die
* Snapshot-Dateien werden beim Schreiben gelesen, sofern noch vorhanden;
* Speicherpfade bleiben außerhalb des Manifests (E.4).
*
* @param array<string, mixed> $state aus depublicationState()
*/
public function recordDepublished(PressRelease $pressRelease, array $state, ArchiveReasonCategory $reason, ?User $actor = null, ?string $note = null, ?LegalRequest $legalRequest = null): ?ArchiveEntry
{
$files = [];
$images = [];
foreach ((array) ($state['images'] ?? []) as $image) {
$path = $image['snapshot_path'] ?? null;
$diskName = $image['snapshot_disk'] ?? null;
unset($image['snapshot_path'], $image['snapshot_disk']);
if (is_string($path) && $path !== '' && is_string($diskName)) {
$disk = Storage::disk($diskName);
if ($disk->exists($path)) {
$name = 'image-'.$image['id'].'.'.pathinfo($path, PATHINFO_EXTENSION);
$files[$name] = (string) $disk->get($path);
$image['file'] = $name;
} else {
// Datei weg (Umsetzungsaudit 02.10.2026, O08): Wurde das
// Bild inzwischen selbst archiviert, verweist der Nachweis
// verbindlich darauf — dort liegen alle Snapshot-Fassungen.
// Sonst bleibt der Auftrag mit sichtbarem Fehler offen,
// statt still einen unvollständigen Nachweis abzulegen.
$image['archived_in'] = $this->imageArchiveReference((int) $image['id']);
}
} else {
// Altbestand ohne Veröffentlichungssnapshot: ausdrücklich
// kennzeichnen, nicht stillschweigend weglassen.
$image['snapshot'] = 'none';
}
$images[] = $entry;
$images[] = $image;
}
return $this->archive->record(
@ -120,18 +194,42 @@ class PressReleaseArchiver
$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(),
'press_release' => (array) ($state['press_release'] ?? []),
'contacts' => (array) ($state['contacts'] ?? []),
'images' => $images,
'state_captured_at' => $state['captured_at'] ?? null,
'legal_request' => $this->legalRequestBlock($legalRequest),
'note' => $note,
],
$files,
['images' => count($images), 'files' => count($files)],
['images' => count($images), 'files' => count($files), 'legal_request_id' => $legalRequest?->getKey()],
$legalRequest?->getKey(),
);
}
/**
* Verweis auf den Archiveintrag, in dem das Bild samt Snapshot-Fassungen
* liegt; ohne einen solchen fehlt der Nachweis — Ausnahme, der Auftrag
* bleibt offen.
*
* @return array{archive_entry_id: int, event: string}
*/
private function imageArchiveReference(int $imageId): array
{
$entry = ArchiveEntry::query()
->where('subject_type', (new PressReleaseImage)->getMorphClass())
->where('subject_id', $imageId)
->whereIn('event', [ArchiveEvent::ImageRemoved->value, ArchiveEvent::ImageReplaced->value])
->latest('id')
->first();
if ($entry === null) {
throw new RuntimeException(sprintf('Beweisarchiv: Snapshot von Bild #%d fehlt und ist nicht anderweitig archiviert — Depublikationsnachweis bleibt offen.', $imageId));
}
return ['archive_entry_id' => (int) $entry->getKey(), 'event' => $entry->event->value];
}
/**
* DSGVO-Änderung (Schwärzung, Korrektur): Diff alt/neu mit Verweis auf
* den Antrag. Der Vorher-Stand kommt aus dem Redaction-Snapshot, damit
@ -206,7 +304,11 @@ class PressReleaseArchiver
*/
private function pressReleaseHeader(PressRelease $pressRelease): array
{
$pressRelease->loadMissing(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class), 'user']);
if (! $pressRelease->relationLoaded('company') || $pressRelease->company === null) {
$pressRelease->load(['company' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)]);
}
$pressRelease->loadMissing('user');
return [
'id' => $pressRelease->getKey(),

View file

@ -6,11 +6,13 @@ use App\Enums\ActivityType;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Services\ActivityLog;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
@ -31,8 +33,9 @@ use Throwable;
* in Logs). Bewusst OHNE KI-Gate und OHNE Credit-Bezug — der DSGVO-Pfad
* ist Compliance-Pflicht (Art. 12 Abs. 5) und darf an keinem Freigabelauf
* oder Bezahlschritt hängen. Deshalb blockiert auch das Beweisarchiv
* nicht: Ein fehlgeschlagener Eintrag lässt `archive_pending_at` stehen
* und wird nachgeholt (N11).
* nicht: Jeder Archiveintrag ist ein Auftrag (ArchivePendingQueue), ein
* fehlgeschlagener bleibt offen und wird nachgeholt (N11, Umsetzungsaudit
* 29.09.2026 A-02/A-10).
*/
class AnonymizationService
{
@ -46,6 +49,7 @@ class AnonymizationService
private readonly PressReleaseImageRemover $imageRemover,
private readonly PressReleaseArchiver $archiver,
private readonly ImageService $imageService,
private readonly ArchivePendingQueue $archiveQueue,
) {}
/**
@ -98,10 +102,11 @@ class AnonymizationService
): PressReleaseRedactionSnapshot {
$totalHits = 0;
$images = collect();
$job = null;
$snapshot = DB::transaction(function () use (
$pressRelease, $terms, $replacement, $detachContacts,
$removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, &$images,
$removeImageIds, $removeAttachmentIds, $legalRequest, &$totalHits, &$images, &$job,
): PressReleaseRedactionSnapshot {
$contactIds = $detachContacts
? $pressRelease->contacts()->pluck('contacts.id')->all()
@ -134,16 +139,31 @@ class AnonymizationService
}
}
// Marker „Archivierung steht aus" in derselben Transaktion (N11).
$pressRelease->setAttribute('archive_pending_at', now());
$pressRelease->save();
// Archivauftrag in derselben Transaktion (N11): Vorher-Stand aus
// dem Snapshot, Nachher-Stand dieser Schwärzung — ein späteres
// Nachholen bildet nicht einen noch späteren Stand ab (A-04).
$job = $this->archiveQueue->push(
(int) $pressRelease->getKey(),
ArchiveEvent::GdprChange,
$snapshot,
ArchiveReasonCategory::Gdpr,
$legalRequest?->getKey(),
Auth::id() !== null ? (int) Auth::id() : null,
'Anonymisierung: '.count($terms).' Begriff(e), '.count($removeImageIds).' Bild(er), '.count($contactIds).' Kontakt(e)',
[
'before' => (array) ($snapshot->encrypted_before['fields'] ?? []),
'after' => $pressRelease->only(self::REDACTABLE_FIELDS),
],
);
if ($contactIds !== []) {
$pressRelease->contacts()->detach($contactIds);
}
foreach ($attachments as $attachment) {
$this->deletePublicFiles($attachment->disk, array_filter([$attachment->path]));
$this->deleteAttachmentFiles($attachment->disk, array_values(array_filter([$attachment->path])));
$attachment->delete();
}
@ -156,22 +176,16 @@ class AnonymizationService
// 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->removeImage($image, $legalRequest);
}
$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();
}
// Aktivitätsverlauf nicht.
$entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest);
// Bewusst ohne Alt-Werte und ohne Schwärzbegriffe — sonst leben die
// personenbezogenen Daten im Verlauf weiter.
@ -194,58 +208,73 @@ class AnonymizationService
}
/**
* 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.
* Bild über den Remover entfernen (N12). Scheitert das Archiv oder die
* Sofortlöschung, muss der Klarname trotzdem sofort aus der
* Auslieferung: Derivate weg, Soft Delete. Der Rest wird als Auftrag
* festgehalten — Archiveintrag für ein veröffentlichtes v2-Bild,
* Sofortlöschung für ein nie veröffentlichtes (A-10) — und vom
* Nachholer erledigt.
*/
private function removeImage(PressReleaseImage $image, ?LegalRequest $legalRequest): bool
private function removeImage(PressReleaseImage $image, ?LegalRequest $legalRequest): void
{
try {
$this->imageRemover->remove($image, Auth::user(), ArchiveEvent::ImageRemoved, ArchiveReasonCategory::Gdpr, $legalRequest);
return true;
return;
} 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);
$current = PressReleaseImage::withTrashed()->find($image->getKey());
if (! $image->trashed()) {
$image->delete();
if ($current === null) {
return;
}
return false;
$this->imageService->deletePressReleaseImage($current->disk, $current->path, $current->variants);
if (! $current->trashed()) {
$current->delete();
}
if ($current->isLegacyRules()) {
return;
}
if ($current->wasEverPublished()) {
$this->archiveQueue->push(
(int) $current->press_release_id,
ArchiveEvent::ImageRemoved,
$current,
ArchiveReasonCategory::Gdpr,
$legalRequest?->getKey(),
Auth::id() !== null ? (int) Auth::id() : null,
'Bild aus Anonymisierung',
);
return;
}
$this->archiveQueue->pushImagePurge($current, $exception->getMessage());
}
}
/**
* @param list<string> $terms
* @param list<int> $removeImageIds
*/
private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, ?LegalRequest $legalRequest, array $terms, array $removeImageIds): ?ArchiveEntry
private function archiveChange(PressRelease $pressRelease, ArchivePendingJob $job, PressReleaseRedactionSnapshot $snapshot, ?LegalRequest $legalRequest): ?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(),
]);
$payload = (array) $job->payload;
return null;
}
return $this->archiveQueue->completeWith($job, fn () => $this->archiver->gdprChange(
$pressRelease,
(array) ($payload['before'] ?? []),
(array) ($payload['after'] ?? []),
$legalRequest,
Auth::user(),
ArchiveReasonCategory::Gdpr,
$job->note,
$snapshot->getKey(),
));
}
/**
@ -278,15 +307,18 @@ class AnonymizationService
}
/**
* Anhänge liegen nach der Privatisierung auf `local`, davor auf
* `public` — gelöscht wird auf der Disk, die die Zeile nennt (A-12).
*
* @param list<string> $paths
*/
private function deletePublicFiles(?string $disk, array $paths): void
private function deleteAttachmentFiles(?string $disk, array $paths): void
{
if ($disk !== 'public' || $paths === []) {
if ($paths === [] || ! in_array($disk ?? 'public', ['public', 'local'], true)) {
return;
}
Storage::disk('public')->delete($paths);
Storage::disk($disk ?? 'public')->delete($paths);
}
/**

View file

@ -3,16 +3,19 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\PressRelease\Classification\ClassificationManager;
use App\Services\PressRelease\Classification\ClassificationResult;
@ -152,7 +155,7 @@ class CorrectionService
$this->assertNotRateLimited($pressRelease);
[$legalRequest, $snapshot] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification): array {
[$legalRequest, $snapshot, $job] = DB::transaction(function () use ($pressRelease, $newValues, $requesterEmail, $method, $classification, $actor): array {
$legalRequest = LegalRequest::query()->create([
'press_release_id' => $pressRelease->id,
'portal' => $pressRelease->portal?->value,
@ -175,15 +178,16 @@ class CorrectionService
], $legalRequest);
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
$pressRelease->save();
$pressRelease->lockSync('Kleine Korrektur '.$legalRequest->reference());
$job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)');
return [$legalRequest, $snapshot];
return [$legalRequest, $snapshot, $job];
});
WebCaches::forgetSitemaps();
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kleine Korrektur (Selbstbedienung)');
$entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
@ -238,7 +242,7 @@ class CorrectionService
return false;
}
$snapshot = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues): PressReleaseRedactionSnapshot {
[$snapshot, $job] = DB::transaction(function () use ($pressRelease, $legalRequest, $classification, $newValues, $actor): array {
$legalRequest->forceFill([
'status' => LegalRequestStatus::Resolved->value,
'payload' => array_merge($legalRequest->payload ?? [], ['fields' => $classification['fields']]),
@ -251,15 +255,16 @@ class CorrectionService
], $legalRequest);
$this->writeChangedFields($pressRelease, $classification['fields'], $newValues);
$pressRelease->forceFill(['archive_pending_at' => now()])->save();
$pressRelease->save();
$pressRelease->lockSync('Kostenpflichtige Änderung '.$legalRequest->reference());
$job = $this->queueArchive($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung');
return $snapshot;
return [$snapshot, $job];
});
WebCaches::forgetSitemaps();
$entry = $this->archiveChange($pressRelease, $snapshot, $legalRequest, $actor, 'Kostenpflichtige Änderung');
$entry = $this->archiveChange($pressRelease, $job, $snapshot, $legalRequest, $actor);
$changedFields = collect($classification['fields'])
->filter(fn (array $meta): bool => $meta['changed'])
@ -284,47 +289,54 @@ class CorrectionService
return true;
}
/**
* Beweisarchiv (F.4): Auftrag in der fachlichen Transaktion mit
* Vorher-Stand aus dem Snapshot und Nachher-Stand dieser Änderung
* (Umsetzungsaudit 29.09.2026, A-02/A-04).
*/
private function queueArchive(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ArchivePendingJob
{
return app(ArchivePendingQueue::class)->push(
(int) $pressRelease->getKey(),
ArchiveEvent::GdprChange,
$snapshot,
ArchiveReasonCategory::Correction,
$legalRequest->getKey(),
$actor?->getKey(),
$note,
[
'before' => (array) ($snapshot->encrypted_before['fields'] ?? []),
'after' => $pressRelease->only(self::CORRECTABLE_FIELDS),
],
);
}
/**
* Eintrag nach dem Commit schreiben. Scheitert die Ablage, bleibt der
* Auftrag offen und `archive:retry-pending` holt ihn nach (N11).
*/
private function archiveChange(PressRelease $pressRelease, ArchivePendingJob $job, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor): ?ArchiveEntry
{
$payload = (array) $job->payload;
return app(ArchivePendingQueue::class)->completeWith($job, fn () => app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
(array) ($payload['before'] ?? []),
(array) ($payload['after'] ?? []),
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
$job->note,
$snapshot->getKey(),
));
}
/**
* Schreibt die geänderten Felder (Text durch den Sanitizer) auf die PM.
*
* @param array<string, array{distance_percent: float, changed: bool}> $classifiedFields
* @param array<string, string|null> $newValues
*/
/**
* Beweisarchiv (F.4): Vorher-Stand aus dem Snapshot, Nachher-Stand aus
* der Meldung. Scheitert die Ablage, bleibt `archive_pending_at`
* stehen und `archive:retry-pending` holt den Eintrag nach (N11).
*/
private function archiveChange(PressRelease $pressRelease, PressReleaseRedactionSnapshot $snapshot, LegalRequest $legalRequest, ?User $actor, string $note): ?ArchiveEntry
{
try {
$entry = app(PressReleaseArchiver::class)->gdprChange(
$pressRelease,
(array) ($snapshot->encrypted_before['fields'] ?? []),
$pressRelease->fresh()?->only(self::CORRECTABLE_FIELDS) ?? [],
$legalRequest,
$actor,
ArchiveReasonCategory::Correction,
$note,
$snapshot->getKey(),
);
} catch (Throwable $exception) {
Log::error('Beweisarchiv: Korrektur konnte nicht archiviert werden, wird nachgeholt.', [
'press_release_id' => $pressRelease->getKey(),
'legal_request_id' => $legalRequest->getKey(),
'error' => $exception->getMessage(),
]);
return null;
}
if ($entry !== null) {
$pressRelease->forceFill(['archive_pending_at' => null])->save();
}
return $entry;
}
private function writeChangedFields(PressRelease $pressRelease, array $classifiedFields, array $newValues): void
{
foreach ($classifiedFields as $field => $meta) {

View file

@ -3,6 +3,7 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseClassification;
@ -14,8 +15,10 @@ use App\Mail\PressReleaseHeldForReview;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\AdminPreset;
use App\Models\ArchivePendingJob;
use App\Models\Company;
use App\Models\KiAudit;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseAiDeclaration;
use App\Models\PressReleaseImage;
@ -24,6 +27,7 @@ use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\PressReleaseArchiver;
use App\Services\Mail\EmailSuppressionService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
@ -32,7 +36,6 @@ use App\Support\WebCaches;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
@ -319,6 +322,13 @@ class PressReleaseService
return;
}
// Schneller Filter (Audit K-01): Eine Meldung, die auf einen Menschen
// wartet, bleibt in der Admin-Queue. Verbindlich prüft publish()
// unter der Zeilensperre.
if ($pressRelease->requiresManualReview()) {
return;
}
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
@ -335,6 +345,10 @@ class PressReleaseService
/**
* @throws BlacklistViolationException Unzulässiges Wort — die PM wurde
* abgelehnt, der Autor benachrichtigt.
* @throws ManualReviewRequiredException Nur bei automatischen Quellen:
* Die Meldung wartet auf eine
* menschliche Entscheidung (E3,
* Audit K-01). Nichts geändert.
* @throws StaleClassificationException Nur bei automatischen Quellen
* (`ki`, `scheduler`): Die jüngste
* gültige Klassifikation gehört zu
@ -360,6 +374,13 @@ class PressReleaseService
// Job bzw. Scheduler liegt VOR der Sperre – hier zählt, was jetzt
// gesperrt ist. Innerhalb der Transaktion nichts ändern.
if (in_array($source, self::AUTOMATIC_PUBLISH_SOURCES, true)) {
// Wartet die Meldung auf einen Menschen (E3), gibt keine
// Automatik sie frei — auch kein spätes Grün nach einer
// Bearbeitung (Audit K-01). Nichts geändert.
if ($locked->requiresManualReview()) {
throw new ManualReviewRequiredException((int) $locked->getKey());
}
$analyzedHash = $this->latestClassificationHash($locked);
if ($this->classificationIsStale($locked, $analyzedHash)) {
@ -650,14 +671,25 @@ class PressReleaseService
$previous = $pressRelease->status;
$pressRelease->update(['status' => PressReleaseStatus::Archived->value, 'archive_pending_at' => now()]);
// Status und Archivauftrag (mit dem Stand zum Zeitpunkt der
// Depublikation, Audit A-04) in einer Transaktion.
$job = DB::transaction(function () use ($pressRelease): ArchivePendingJob {
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
return $this->queueDepublication($pressRelease, ArchiveReasonCategory::OwnDecision);
});
// Kopien dieser Erstquelle neu bewerten wie bei den anderen
// Depublikationspfaden (Audit N19-Rest).
$this->releaseCanonicalCopies($pressRelease);
// Sichtbarkeitsregel (Roadmap 1b.1/1b.5): Ein Newsroom ist öffentlich
// nur mit mindestens einer veröffentlichten Meldung. Fällt die letzte
// weg, muss auch seine Seite sofort weg — nicht erst nach Ablauf des
// Seiten-Caches.
WebCaches::forgetPublicPages();
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision);
$this->archiveDepublished($pressRelease, $job, ArchiveReasonCategory::OwnDecision);
}
/**
@ -672,35 +704,37 @@ class PressReleaseService
* Setzt IMMER die Re-Sync-Sperre — sonst würde der nächste Importer-Lauf
* die Entfernung überschreiben (Meldeverfahren §7). Aufrufer können über
* $syncLockReason einen spezifischeren Grund (z. B. die Vorgangsnummer)
* ins Sperr-Protokoll geben.
* ins Sperr-Protokoll geben. Kommt die Depublikation aus dem
* Meldeverfahren, trägt der Archiveintrag den Vorgang (5.1 d).
*/
public function depublish(PressRelease $pressRelease, ?string $syncLockReason = null): void
public function depublish(PressRelease $pressRelease, ?string $syncLockReason = null, ?LegalRequest $legalRequest = null): void
{
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
$previous = $pressRelease->status;
$reason = $syncLockReason !== null || $legalRequest !== null ? ArchiveReasonCategory::ReportProcedure : ArchiveReasonCategory::CustomerRequest;
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'depublished_at' => now(),
'archive_pending_at' => now(),
]);
$job = DB::transaction(function () use ($pressRelease, $reason, $syncLockReason, $legalRequest): ArchivePendingJob {
$pressRelease->update([
'status' => PressReleaseStatus::Archived->value,
'depublished_at' => now(),
]);
return $this->queueDepublication($pressRelease, $reason, $syncLockReason, $legalRequest);
});
$pressRelease->lockSync($syncLockReason ?? 'Depublikation auf Wunsch des Absenders');
$this->releaseCanonicalCopies($pressRelease);
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,
);
$this->archiveDepublished($pressRelease, $job, $reason, $syncLockReason, $legalRequest);
}
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
{
$previous = $pressRelease->status;
$job = null;
DB::transaction(function () use ($pressRelease, $status, $reason): void {
DB::transaction(function () use ($pressRelease, $status, $reason, &$job): void {
$locked = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->whereKey($pressRelease->getKey())
@ -721,12 +755,13 @@ class PressReleaseService
'published_ever_at' => $status === PressReleaseStatus::Published
? ($locked->published_ever_at ?? now())
: $locked->published_ever_at,
// Archivierung steht aus, bis der Eintrag geschrieben ist (N11).
'archive_pending_at' => $previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published
? now()
: $locked->archive_pending_at,
]);
// Archivauftrag in derselben Transaktion (N11, Audit A-02/A-04).
if ($previous === PressReleaseStatus::Published && $status !== PressReleaseStatus::Published) {
$job = $this->queueDepublication($locked, ArchiveReasonCategory::OwnDecision, $reason);
}
if ($status === PressReleaseStatus::Published) {
$this->markImagesPublished($locked);
}
@ -750,7 +785,10 @@ class PressReleaseService
}
} elseif ($previous === PressReleaseStatus::Published) {
$this->releaseCanonicalCopies($pressRelease);
$this->archiveDepublished($pressRelease, ArchiveReasonCategory::OwnDecision, $reason);
}
if ($job instanceof ArchivePendingJob) {
$this->archiveDepublished($pressRelease, $job, ArchiveReasonCategory::OwnDecision, $reason);
}
}
@ -789,30 +827,39 @@ class PressReleaseService
}
/**
* Beweisarchiv (Block F.4): letzter veröffentlichter Stand, sobald eine
* Meldung das öffentliche Angebot verlässt. Der Marker
* `archive_pending_at` wurde in der fachlichen Transaktion gesetzt und
* fällt erst mit dem Eintrag; scheitert die Ablage, bleibt er stehen
* und `archive:retry-pending` holt den Eintrag nach (N11). Nur
* deleteFromAdmin() archiviert vor dem Überschreiben und bleibt
* blockierend.
* Beweisarchiv (Block F.4): Sobald eine Meldung das öffentliche Angebot
* verlässt, entsteht in der fachlichen Transaktion ein Archivauftrag mit
* dem Stand zu diesem Zeitpunkt (Umsetzungsaudit 29.09.2026, A-02/A-04).
* Nur deleteFromAdmin() archiviert direkt und bleibt blockierend.
*/
private function archiveDepublished(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null): void
private function queueDepublication(PressRelease $pressRelease, ArchiveReasonCategory $reason, ?string $note = null, ?LegalRequest $legalRequest = null): ArchivePendingJob
{
try {
$entry = app(PressReleaseArchiver::class)->pressReleaseDepublished($pressRelease, $reason, Auth::user(), $note);
} catch (\Throwable $exception) {
Log::error('Beweisarchiv: Depublikation konnte nicht archiviert werden, wird nachgeholt.', [
'press_release_id' => $pressRelease->getKey(),
'error' => $exception->getMessage(),
]);
return app(ArchivePendingQueue::class)->push(
(int) $pressRelease->getKey(),
ArchiveEvent::PressReleaseDepublished,
$pressRelease,
$reason,
$legalRequest?->getKey(),
Auth::id() !== null ? (int) Auth::id() : null,
$note,
['state' => app(PressReleaseArchiver::class)->depublicationState($pressRelease)],
);
}
return;
}
/**
* Nach dem Commit den Eintrag aus dem festgehaltenen Stand schreiben.
* Scheitert die Ablage, bleibt der Auftrag offen und
* `archive:retry-pending` holt ihn nach (N11); der Vorgang selbst läuft
* weiter.
*/
private function archiveDepublished(PressRelease $pressRelease, ArchivePendingJob $job, ArchiveReasonCategory $reason, ?string $note = null, ?LegalRequest $legalRequest = null): void
{
$state = (array) ($job->payload['state'] ?? []);
if ($entry !== null && $pressRelease->archive_pending_at !== null) {
$pressRelease->forceFill(['archive_pending_at' => null])->save();
}
app(ArchivePendingQueue::class)->completeWith(
$job,
fn () => app(PressReleaseArchiver::class)->recordDepublished($pressRelease, $state, $reason, Auth::user(), $note, $legalRequest),
);
}
/**
@ -833,17 +880,28 @@ class PressReleaseService
}
}
/**
* Nur öffentlich auslieferbare Bilder gelten als veröffentlicht
* (Umsetzungsaudit 29.09., N09): ein zurückgehaltenes Bild war nie
* öffentlich und wird bei späterer Freigabe mit dem Snapshot markiert.
*/
private function markImagesPublished(PressRelease $pressRelease): void
{
$pressRelease->images()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
$pressRelease->images()->publiclyVisible()->whereNull('published_ever_at')->update(['published_ever_at' => now()]);
}
/**
* Snapshot der veröffentlichten Bildfassung nach dem Commit (E.2).
* Nebensache nach dem N08-Muster: Ein Fehler wird gemeldet, Canonical
* und Autorenmail laufen weiter (Umsetzungsaudit 29.09., N09).
*/
private function capturePublishedImages(PressRelease $pressRelease): void
{
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
try {
app(PublishedImageSnapshot::class)->captureForPressRelease($pressRelease);
} catch (\Throwable $e) {
report($e);
}
}
/**

View file

@ -24,8 +24,26 @@ return [
'disk' => env('ARCHIVE_DISK', 'archive'),
'key' => env('ARCHIVE_KEY'),
/*
| Schlüsselrotation (Umsetzungsaudit 29.09.2026, A-15): frühere Schlüssel,
| kommagetrennt (base64:…). Jeder Eintrag trägt die ID seines Schlüssels;
| entschlüsselt wird mit dem passenden. Einen früheren Schlüssel erst
| entfernen, wenn der Systemstatus keine Einträge mit ihm mehr meldet.
*/
'previous_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('ARCHIVE_PREVIOUS_KEYS', ''))))),
'cipher' => 'AES-256-CBC',
'retention_years' => (int) env('ARCHIVE_RETENTION_YEARS', 3),
'tombstone_export_path' => 'tombstones/latest.json',
/*
| Orphan-Sweep (A-03): Payloads ohne Zeile wandern in die Quarantäne
| (`quarantine/JJJJ-MM-TT/…`) und fallen erst nach quarantine_days.
| sweep_paused setzt den Sweep hart aus (zusätzlich zum Schalter
| `archive:sweep-orphans --pause`, den der Restore-Abgleich setzt).
*/
'quarantine_days' => (int) env('ARCHIVE_QUARANTINE_DAYS', 30),
'sweep_paused' => (bool) env('ARCHIVE_SWEEP_PAUSED', false),
];

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-02/A-04/A-09/A-10): ein
* Auftrag je ausstehendem Archiveintrag statt eines einzigen Zeitstempels
* an der Meldung. Der Auftrag entsteht in der fachlichen Transaktion und
* trägt, was der Nachholer braucht — bei `pm_depublished` den Stand zum
* Zeitpunkt der Depublikation (verschlüsselt, APP_KEY wie die
* Redaction-Snapshots). Er wird gelöscht, sobald der Eintrag geschrieben
* ist; `press_releases.archive_pending_at` bleibt als Sammelmarker
* (Systemstatus, Kompatibilität) und fällt erst mit dem letzten Auftrag.
*
* Kein Fremdschlüssel auf press_releases: Aufträge sollen eine
* Hartlöschung der Meldung überleben und im Systemstatus sichtbar bleiben.
* Neue, kleine Tabelle — läuft sofort.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('archive_pending_jobs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('press_release_id');
$table->string('event', 30);
$table->string('subject_type', 80)->nullable();
$table->unsignedBigInteger('subject_id')->nullable();
$table->string('reason_category', 30)->nullable();
$table->unsignedBigInteger('legal_request_id')->nullable();
$table->unsignedBigInteger('actor_id')->nullable();
$table->string('note', 500)->nullable();
$table->longText('payload')->nullable();
$table->unsignedInteger('attempts')->default(0);
$table->string('last_error', 1000)->nullable();
$table->timestamp('last_attempted_at')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['press_release_id'], 'archive_pending_jobs_press_release_idx');
$table->index(['event', 'created_at'], 'archive_pending_jobs_event_created_idx');
});
}
public function down(): void
{
Schema::dropIfExists('archive_pending_jobs');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026):
*
* - `key_id` (A-15): kurze Kennung des Schlüssels, mit dem der Payload
* verschlüsselt wurde. Nach einer Rotation (ARCHIVE_PREVIOUS_KEYS)
* entschlüsselt das Archiv mit dem passenden Schlüssel; Bestand ohne
* Kennung probiert alle bekannten Schlüssel.
* - Index auf `payload_path` (OPT-6): Orphan-Sweep, Wiederregistrierung und
* Restore-Abgleich suchen nach Pfad.
*
* archive_entries ist klein und ohne FULLTEXT — ADD COLUMN und ADD INDEX
* laufen online.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('archive_entries', function (Blueprint $table) {
$table->char('key_id', 16)->nullable()->after('payload_bytes');
$table->index(['payload_path'], 'archive_entries_payload_path_idx');
});
}
public function down(): void
{
Schema::table('archive_entries', function (Blueprint $table) {
$table->dropIndex('archive_entries_payload_path_idx');
$table->dropColumn('key_id');
});
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Beweisarchiv (Umsetzungsaudit 29.09.2026, A-07): Name und E-Mail des
* Admins werden mitgeschrieben. `admin_id` bleibt `nullOnDelete` (eine
* Kontolöschung soll nicht am Zugriffsprotokoll scheitern) — nach einer
* Löschung weist die Zeile den Zugreifenden trotzdem aus. Kleine Tabelle,
* ADD COLUMN läuft sofort.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('archive_access_log', function (Blueprint $table) {
$table->string('admin_name')->nullable()->after('admin_id');
$table->string('admin_email')->nullable()->after('admin_name');
});
}
public function down(): void
{
Schema::table('archive_access_log', function (Blueprint $table) {
$table->dropColumn(['admin_name', 'admin_email']);
});
}
};

View file

@ -1,12 +1,14 @@
{{-- Zweck eines Zugriffs auf Beweisdaten (Archiv, Beweisoriginal): nur per POST mit CSRF, nie in der URL (Umsetzungsaudit 29.09.2026, A-08). --}}
<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:text>{{ $description }}</flux:text>
<form method="post" action="{{ $formAction }}" class="space-y-3">
@csrf
<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>
<flux:button type="submit" variant="primary">{{ $submitLabel }}</flux:button>
<flux:button variant="filled" href="{{ $cancelUrl }}">{{ __('Abbrechen') }}</flux:button>
</div>
</form>
</div>

View file

@ -131,7 +131,7 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
return;
}
$service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference());
$service->depublish($pressRelease, 'Depublizierung im Meldeverfahren '.$request->reference(), $request);
$request->update([
'status' => LegalRequestStatus::Resolved->value,

View file

@ -45,10 +45,12 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Volt::route('admin/press-releases/{id}', 'admin.press-releases.show')->name('admin.press-releases.show');
Volt::route('admin/press-releases/{id}/edit', 'admin.press-releases.edit')->name('admin.press-releases.edit');
// Beweisoriginal und veröffentlichte Fassung (Bildkennzeichnung E.4):
// nur hier, nur mit Zweck, jeder Abruf protokolliert.
Route::get('admin/press-releases/{id}/images/{image}/original', [PressReleaseImageEvidenceController::class, 'original'])
// nur hier, nur mit Zweck (per POST, Audit A-08), jeder Abruf
// protokolliert; Restnachweise entfernter Bilder nur mit
// compliance:manage (Audit A-11, im Controller).
Route::match(['get', 'post'], 'admin/press-releases/{id}/images/{image}/original', [PressReleaseImageEvidenceController::class, 'original'])
->whereNumber(['id', 'image'])->name('admin.press-releases.images.original');
Route::get('admin/press-releases/{id}/images/{image}/published', [PressReleaseImageEvidenceController::class, 'published'])
Route::match(['get', 'post'], 'admin/press-releases/{id}/images/{image}/published', [PressReleaseImageEvidenceController::class, 'published'])
->whereNumber(['id', 'image'])->name('admin.press-releases.images.published');
// Rubriken-Taxonomie: portalweite Struktur — nur admin (Befund H2).
@ -136,8 +138,8 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
// 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');
Route::match(['get', 'post'], 'admin/archive/{entry}/download', [ArchiveEntryController::class, 'download'])->whereNumber('entry')->name('admin.archive.download');
Route::match(['get', 'post'], 'admin/archive/{entry}/export', [ArchiveEntryController::class, 'export'])->whereNumber('entry')->name('admin.archive.export');
});
// Reports

View file

@ -0,0 +1,826 @@
<?php
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\ConfirmationChannel;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\AppSetting;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ImageDeclarationCorrection;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\Admin\AdminSystemHealth;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use App\Services\CurrentPortalContext;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\Support\FailingDisk;
use Tests\TestCase;
/**
* Beweisarchiv, Löschung, Anonymisierung — Umsetzungsaudit 29.09.2026
* (A-01 bis A-15, OPT-6, 5.1 d, PortalScope im Archiver).
*/
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)));
config()->set('archive.previous_keys', []);
config()->set('archive.sweep_paused', false);
AppSetting::forgetCached('archive.sweep_paused');
});
afterEach(function (): void {
CurrentPortalContext::clear();
});
function followUpAdmin(string $role = 'admin'): User
{
$admin = User::factory()->create(['is_active' => true, 'name' => 'Erika Prüferin']);
$admin->assignRole($role);
return $admin;
}
/**
* @return array{pr: PressRelease, image: PressReleaseImage}
*/
function followUpPublished(array $imageAttributes = []): 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(),
'text' => '<p>Max Mustermann war dabei.</p>',
'slug' => 'folge-'.fake()->unique()->numberBetween(1, 99999),
]);
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create($imageAttributes + [
'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');
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()];
}
/**
* @return array<string, mixed>
*/
function followUpManifest(ArchiveEntry $entry): array
{
return app(EvidenceArchive::class)->manifest($entry, followUpAdmin(), 'Test');
}
// ---------------------------------------------------------------------------
// A-01: Nachholer ohne Schlüssel
// ---------------------------------------------------------------------------
test('without an archive key the retry fails, keeps every job and the system status reports the missing key', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
config()->set('archive.key', null);
app(PressReleaseService::class)->depublish($pr);
expect(ArchivePendingJob::query()->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->expectsOutputToContain('ARCHIVE_KEY')->assertFailed();
expect(ArchivePendingJob::query()->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull()
->and(ArchiveEntry::query()->count())->toBe(0);
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('critical')
->and($checks->get('archive_pending')['detail'])->toContain('1 offene(r) Archivauftrag');
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('ok')
->and($checks->get('archive_pending')['status'])->toBe('ok');
});
// ---------------------------------------------------------------------------
// A-02 / A-04: Aufträge je Vorgang statt eines Zeitstempels
// ---------------------------------------------------------------------------
test('a later successful anonymisation neither overwrites nor clears the open depublication job', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
$marker = $pr->fresh()->archive_pending_at;
expect($marker)->not->toBeNull();
$this->travel(2)->hours();
app(AnonymizationService::class)->execute($pr->fresh(), ['Max Mustermann']);
expect(ArchiveEntry::query()->where('event', 'gdpr_change')->count())->toBe(1)
->and(ArchivePendingJob::query()->pluck('event')->all())->toBe(['pm_depublished'])
->and($pr->fresh()->archive_pending_at?->toDateTimeString())->toBe($marker->toDateTimeString());
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
// A-04: der Nachweis zeigt den Stand der Depublikation, nicht den geschwärzten.
$depublished = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
expect(followUpManifest($depublished)['press_release']['text'])->toContain('Max Mustermann')
->and($pr->fresh()->archive_pending_at)->toBeNull()
->and(ArchivePendingJob::query()->count())->toBe(0);
});
test('a retried depublication archives the state at depublication even after later edits', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
$pr->fresh()->forceFill(['text' => '<p>Später bearbeitet.</p>', 'title' => 'Neuer Titel'])->save();
$this->artisan('archive:retry-pending')->assertSuccessful();
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'pm_depublished')->sole());
expect($manifest['press_release']['text'])->toContain('Max Mustermann')
->and($manifest['press_release']['title'])->not->toBe('Neuer Titel')
->and($manifest['images'])->toHaveCount(1)
->and($manifest['images'][0])->not->toHaveKey('snapshot_path');
});
test('a retried depublication without its snapshot file stays open, or points to the image archive entry', function () {
/** @var TestCase $this */
// Umsetzungsaudit 02.10.2026, O08: Eine fehlende Snapshot-Datei wurde
// still ausgelassen und der Auftrag trotzdem als erledigt verbucht.
['pr' => $pr, 'image' => $image] = followUpPublished();
$admin = followUpAdmin();
$this->actingAs($admin);
$snapshot = (string) $image->published_snapshot_path;
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
// Datei verloren, Bild nirgends archiviert: sichtbar offen bleiben.
Storage::disk('local')->move($snapshot, $snapshot.'.weg');
$this->artisan('archive:retry-pending')->assertFailed();
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->exists())->toBeFalse()
->and(ArchivePendingJob::query()->where('event', 'pm_depublished')->sole()->last_error)->toContain('Snapshot');
// Das Bild wird regulär entfernt und mit seinen Snapshots archiviert;
// der Remover räumt die lokalen Dateien danach weg.
Storage::disk('local')->move($snapshot.'.weg', $snapshot);
app(PressReleaseImageRemover::class)->remove($image->fresh(), $admin);
$removed = ArchiveEntry::query()->where('event', 'image_removed')->sole();
expect(Storage::disk('local')->exists($snapshot))->toBeFalse();
$this->artisan('archive:retry-pending')->assertSuccessful();
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'pm_depublished')->sole());
expect($manifest['images'][0]['archived_in'])->toBe(['archive_entry_id' => $removed->id, 'event' => 'image_removed'])
->and($manifest['images'][0])->not->toHaveKey('file');
});
test('a failed anonymisation of an already archived release does not invent a depublication entry', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
app(PressReleaseService::class)->archive($pr);
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->count())->toBe(1);
FailingDisk::install('archive', ['put' => fn () => false]);
app(AnonymizationService::class)->execute($pr->fresh(), ['Max Mustermann']);
FailingDisk::restore('archive');
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->count())->toBe(1)
->and(ArchiveEntry::query()->where('event', 'gdpr_change')->count())->toBe(1);
// Nachher-Stand ist der Stand der Schwärzung, nicht der beim Nachholen.
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'gdpr_change')->sole());
expect($manifest['after']['text'])->toContain('[entfernt]');
});
test('pushing a job keeps an older marker and never touches updated_at', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$older = now()->subDay()->startOfSecond();
DB::table('press_releases')->where('id', $pr->id)->update(['archive_pending_at' => $older, 'updated_at' => now()->subWeek()]);
$updatedAt = $pr->fresh()->updated_at->toDateTimeString();
app(ArchivePendingQueue::class)->push($pr->id, ArchiveEvent::GdprChange, $pr, ArchiveReasonCategory::Gdpr, payload: ['before' => [], 'after' => []]);
expect($pr->fresh()->archive_pending_at->toDateTimeString())->toBe($older->toDateTimeString())
->and($pr->fresh()->updated_at->toDateTimeString())->toBe($updatedAt)
// Der Altmarker ohne Auftrag wird zum eigenen Auftrag und geht nicht verloren.
->and(ArchivePendingJob::query()->orderBy('id')->pluck('event')->all())->toBe(['legacy_marker', 'gdpr_change']);
});
test('the queue api records a label correction job that the retry command completes', function () {
/** @var TestCase $this */
['pr' => $pr, 'image' => $image] = followUpPublished();
$admin = followUpAdmin();
$correction = ImageDeclarationCorrection::query()->create([
'press_release_image_id' => $image->id,
'press_release_id' => $pr->id,
'admin_id' => $admin->id,
'field' => ImageDeclarationCorrection::FIELD_CREDIT,
'old_value' => 'Anna Beispiel',
'new_value' => 'Bernd Beispiel',
'reason' => 'Einwand',
'created_at' => now(),
]);
$queue = app(ArchivePendingQueue::class);
$job = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::LabelCorrected, $correction, ArchiveReasonCategory::Correction, actorId: $admin->id));
expect($job->event)->toBe('label_corrected')
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
$entry = ArchiveEntry::query()->where('event', 'label_corrected')->sole();
expect($entry->summary['correction_id'])->toBe($correction->id)
->and($entry->actor_type)->toBe('admin')
->and($pr->fresh()->archive_pending_at)->toBeNull();
// completeWith: Auftrag fällt mit dem Eintrag, Marker nur ohne weitere Aufträge.
$second = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::LabelCorrected, $correction, ArchiveReasonCategory::Correction, actorId: $admin->id));
$entry = $queue->completeWith($second, fn () => null);
expect($entry)->toBeNull()
->and($second->fresh()->attempts)->toBe(1)
->and($second->fresh()->last_error)->toContain('nicht archiviert');
});
test('a job completed elsewhere is not archived a second time from a stale instance', function () {
/** @var TestCase $this */
// Umsetzungsaudit 02.10.2026, O07: Direktweg und Nachholer starteten
// mit einer geladenen Instanz, ohne die Auftragszeile zu beanspruchen.
['pr' => $pr] = followUpPublished();
$queue = app(ArchivePendingQueue::class);
$job = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::GdprChange, null, ArchiveReasonCategory::Gdpr, payload: ['before' => ['text' => 'alt'], 'after' => ['text' => 'neu']]));
$stale = clone $job;
$first = app(PendingArchiveRetry::class)->retry($job);
$second = app(PendingArchiveRetry::class)->retry($stale);
expect($first['recorded'])->toBe(1)
->and($second)->toBe(['recorded' => 0, 'skipped' => 0, 'failed' => 0])
->and(ArchiveEntry::query()->where('event', ArchiveEvent::GdprChange->value)->count())->toBe(1);
// Direktweg mit veralteter Instanz: der Schritt läuft gar nicht erst,
// und es wird kein Fehlversuch verbucht.
$ran = false;
expect($queue->completeWith($stale, function () use (&$ran) {
$ran = true;
return null;
}))->toBeNull()
->and($ran)->toBeFalse()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::GdprChange->value)->count())->toBe(1);
});
// ---------------------------------------------------------------------------
// A-10, A-12: Anonymisierung
// ---------------------------------------------------------------------------
test('a failed immediate deletion of a never published image during anonymisation stays queued and is completed later', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['published_ever_at' => null]);
Storage::disk('local')->put($image->original_path, 'original-bytes');
$this->actingAs(followUpAdmin());
FailingDisk::install('local', ['delete' => fn () => false]);
app(AnonymizationService::class)->execute($pr, [], removeImageIds: [$image->id]);
FailingDisk::restore('local');
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(ArchivePendingJob::query()->where('event', 'image_purge')->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->assertSuccessful();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('anonymisation deletes attachments on the disk they actually live on', function () {
['pr' => $pr] = followUpPublished();
$local = PressReleaseAttachment::factory()->for($pr)->create(['disk' => 'local', 'path' => 'press-release-attachments/'.$pr->id.'/a.pdf']);
$public = PressReleaseAttachment::factory()->for($pr)->create(['disk' => 'public', 'path' => 'press-release-attachments/'.$pr->id.'/b.pdf']);
Storage::disk('local')->put($local->path, 'pdf');
Storage::disk('public')->put($public->path, 'pdf');
app(AnonymizationService::class)->execute($pr, [], removeAttachmentIds: [$local->id, $public->id]);
expect(Storage::disk('local')->exists($local->path))->toBeFalse()
->and(Storage::disk('public')->exists($public->path))->toBeFalse();
});
// ---------------------------------------------------------------------------
// A-13: Snapshots bleiben bei offenem Auftrag
// ---------------------------------------------------------------------------
test('snapshots of releases with an open archive job survive the snapshot purge', function () {
/** @var TestCase $this */
['pr' => $pending] = followUpPublished();
['pr' => $done] = followUpPublished();
$keep = PressReleaseRedactionSnapshot::capture($pending, ['fields' => ['text' => 'alt']]);
$drop = PressReleaseRedactionSnapshot::capture($done, ['fields' => ['text' => 'alt']]);
PressReleaseRedactionSnapshot::query()->update(['purge_after' => now()->subDays(2)->toDateString()]);
DB::transaction(fn () => app(ArchivePendingQueue::class)->push($pending->id, ArchiveEvent::GdprChange, $keep, ArchiveReasonCategory::Gdpr, payload: ['before' => [], 'after' => []]));
$this->artisan('snapshots:purge')->assertSuccessful();
expect(PressReleaseRedactionSnapshot::query()->whereKey($keep->id)->exists())->toBeTrue()
->and(PressReleaseRedactionSnapshot::query()->whereKey($drop->id)->exists())->toBeFalse();
});
// ---------------------------------------------------------------------------
// A-03: Sweep nach Restore — Quarantäne, Schalter, Wiederregistrierung
// ---------------------------------------------------------------------------
test('the orphan sweep moves payloads into quarantine and deletes them only after the quarantine period', function () {
/** @var TestCase $this */
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
Storage::disk('archive')->put('entries/2026/stale.bin.tmp', 'x');
touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000);
touch(Storage::disk('archive')->path('entries/2026/stale.bin.tmp'), time() - 90000);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 1 in Quarantäne, 1 entfernt')->assertSuccessful();
$quarantined = 'quarantine/'.now()->toDateString().'/entries/2026/stale.bin';
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse()
->and(Storage::disk('archive')->exists($quarantined))->toBeTrue();
$this->travel(29)->days();
$this->artisan('archive:sweep-orphans')->assertSuccessful();
expect(Storage::disk('archive')->exists($quarantined))->toBeTrue();
$this->travel(2)->days();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('Quarantäne abgelaufen: 1')->assertSuccessful();
expect(Storage::disk('archive')->exists($quarantined))->toBeFalse();
});
test('the sweep can be paused by config or switch and the restore reconcile pauses it automatically', function () {
/** @var TestCase $this */
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000);
config()->set('archive.sweep_paused', true);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('ausgesetzt')->assertSuccessful();
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeTrue();
config()->set('archive.sweep_paused', false);
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('Sweep ausgesetzt')->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('ausgesetzt')->assertSuccessful();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_sweep')['status'])->toBe('warn');
$this->artisan('archive:sweep-orphans', ['--resume' => true])->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('1 in Quarantäne')->assertSuccessful();
$this->artisan('archive:sweep-orphans', ['--pause' => true])->assertSuccessful();
expect(AppSetting::getBool('archive.sweep_paused'))->toBeTrue();
});
test('reregister restores archive rows for payloads from entries and quarantine using their manifest', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
$archive = app(EvidenceArchive::class);
$first = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'admin', 7, ['x' => 1], [], ['files' => 0], $legalRequest->id);
$second = $archive->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'system', null, ['x' => 2]);
// „DB-Restore auf einen Stand vor beiden Einträgen": Zeilen weg, Dateien da.
DB::table('archive_entries')->delete();
touch(Storage::disk('archive')->path($second->payload_path), time() - 90000);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('1 in Quarantäne')->assertSuccessful();
// Kein Tombstone-Export vorhanden: nur auf ausdrückliche Anweisung (O05).
$this->artisan('archive:reregister')->expectsOutputToContain('Kein Tombstone-Export')->assertFailed();
$this->artisan('archive:reregister', ['--dry-run' => true, '--without-export' => true])->expectsOutputToContain('2 Payload(s) ohne Zeile')->assertSuccessful();
expect(ArchiveEntry::query()->count())->toBe(0);
$this->artisan('archive:reregister', ['--without-export' => true])->expectsOutputToContain('2 wieder registriert')->assertSuccessful();
$restoredFirst = ArchiveEntry::query()->where('payload_path', $first->payload_path)->sole();
$restoredSecond = ArchiveEntry::query()->where('payload_path', $second->payload_path)->sole();
expect($restoredFirst->event)->toBe(ArchiveEvent::ImageRemoved)
->and($restoredFirst->legal_request_id)->toBe($legalRequest->id)
->and($restoredFirst->actor_id)->toBe(7)
->and($restoredFirst->payload_hash)->toBe($first->payload_hash)
->and($restoredFirst->retain_until->toDateString())->toBe($first->retain_until->toDateString())
->and($restoredSecond->event)->toBe(ArchiveEvent::PressReleaseDepublished)
->and(Storage::disk('archive')->exists($second->payload_path))->toBeTrue()
->and(followUpManifest($restoredSecond)['x'])->toBe(2);
$this->artisan('archive:reregister', ['--without-export' => true])->expectsOutputToContain('0 Payload(s) ohne Zeile')->assertSuccessful();
});
// ---------------------------------------------------------------------------
// A-06: Restore-Abgleich löscht Anwendungskopien erneut
// ---------------------------------------------------------------------------
test('the restore reconcile purges restored application copies of a removed image again', function () {
/** @var TestCase $this */
['image' => $image] = followUpPublished();
app(PressReleaseImageRemover::class)->remove($image, followUpAdmin());
$entry = ArchiveEntry::query()->where('event', 'image_removed')->sole();
$imageRow = (array) DB::table('press_release_images')->where('id', $image->id)->first();
$confirmationRows = DB::table('image_rights_confirmations')->where('subject_id', $image->id)->get()->map(fn ($row) => (array) $row)->all();
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
$this->artisan('archive:purge')->assertSuccessful();
$this->artisan('archive:export-tombstones')->assertSuccessful();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse();
// „DB-Restore": Zeilen und Payload kommen zurück.
DB::table('press_release_images')->insert($imageRow);
DB::table('image_rights_confirmations')->insert($confirmationRows);
DB::table('archive_entries')->where('id', $entry->id)->update(['deleted_at' => null]);
Storage::disk('archive')->put($entry->payload_path, 'restored-cipher');
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Einträge erneut')->assertSuccessful();
expect($entry->fresh()->isTombstoned())->toBeTrue()
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(0);
});
// ---------------------------------------------------------------------------
// A-05, A-14: purge-drafts
// ---------------------------------------------------------------------------
test('legacy rejected releases count as possibly published and keep their media in the draft purge', function () {
/** @var TestCase $this */
['pr' => $legacy] = followUpPublished(['published_ever_at' => null]);
$legacy->forceFill(['status' => PressReleaseStatus::Rejected, 'published_ever_at' => null, 'legacy_id' => 424242, 'legacy_portal' => 'presseecho'])->save();
DB::table('press_releases')->where('id', $legacy->id)->update(['updated_at' => now()->subDays(400)]);
expect($legacy->fresh()->wasEverPublished())->toBeTrue()
->and(PressRelease::query()->neverPublished()->whereKey($legacy->id)->exists())->toBeFalse();
$this->artisan('press-releases:purge-drafts', ['--days' => 180])
->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 0, Bilder gelöscht: 0')
->assertSuccessful();
expect(PressReleaseImage::query()->where('press_release_id', $legacy->id)->count())->toBe(1);
});
test('one broken image does not stop the draft purge, which reports the failure at the end', function () {
/** @var TestCase $this */
['pr' => $broken] = followUpPublished(['published_ever_at' => null]);
['pr' => $healthy] = followUpPublished(['published_ever_at' => null]);
PressRelease::query()->whereKey([$broken->id, $healthy->id])->update(['status' => PressReleaseStatus::Draft->value, 'published_ever_at' => null, 'updated_at' => now()->subDays(200)]);
$brokenImage = $broken->images()->sole();
FailingDisk::install('local', ['delete' => fn ($path, $real) => in_array($brokenImage->original_path, (array) $path, true) ? false : $real()]);
$this->artisan('press-releases:purge-drafts', ['--days' => 180])
->expectsOutputToContain('fehlgeschlagen: 1')
->assertFailed();
FailingDisk::restore('local');
expect(PressReleaseImage::query()->where('press_release_id', $healthy->id)->count())->toBe(0)
->and($healthy->fresh()->status)->toBe(PressReleaseStatus::Archived)
->and(PressReleaseImage::query()->whereKey($brokenImage->id)->exists())->toBeTrue()
->and($broken->fresh()->status)->toBe(PressReleaseStatus::Draft);
});
// ---------------------------------------------------------------------------
// A-07, A-08, A-11: Zugriff und Protokoll
// ---------------------------------------------------------------------------
test('setting and releasing a legal hold is logged with admin name and email and keeps the history', function () {
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$entry = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$archive->setLegalHold($entry, $admin, 'Abmahnung Az. 12/2026');
$archive->releaseLegalHold($entry->fresh(), $admin, 'Verfahren beendet');
$archive->manifest($entry->fresh(), $admin, 'Auskunft');
$log = ArchiveAccessLog::query()->orderBy('id')->get();
expect($log->pluck('action')->all())->toBe(['hold_set', 'hold_released', 'manifest'])
->and($log->pluck('purpose')->all())->toBe(['Abmahnung Az. 12/2026', 'Verfahren beendet', 'Auskunft'])
->and($log->first()->admin_name)->toBe('Erika Prüferin')
->and($log->first()->admin_email)->toBe($admin->email);
});
test('archive downloads take the purpose only by post, never from the url', function () {
/** @var TestCase $this */
$admin = followUpAdmin();
$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/'.$entry->id.'/download?purpose=Geheim')
->assertSuccessful()
->assertSeeText('Zweck des Zugriffs')
->assertSee('method="post"', false);
expect(ArchiveAccessLog::query()->count())->toBe(0);
$this->actingAs($admin)->post('https://pressekonto.test/admin/archive/'.$entry->id.'/export', ['purpose' => 'Anwalt'])
->assertSuccessful()
->assertHeader('Content-Type', 'application/zip');
expect(ArchiveAccessLog::query()->sole()->purpose)->toBe('Anwalt');
});
test('evidence files take the purpose by post and local leftovers of removed images need compliance rights', function () {
/** @var TestCase $this */
['pr' => $pr, 'image' => $image] = followUpPublished();
$url = 'https://pressekonto.test/admin/press-releases/'.$pr->id.'/images/'.$image->id.'/original';
$editor = followUpAdmin('editor');
$this->actingAs($editor)->get($url.'?purpose=Geheim')->assertSuccessful()->assertSeeText('Zweck des Zugriffs');
$response = $this->actingAs($editor)->post($url, ['purpose' => 'Sichtprüfung']);
$response->assertSuccessful();
expect($response->streamedContent())->toBe('original-bytes');
// Entferntes Bild mit liegengebliebenem Original: nur compliance:manage.
$image->delete();
$this->actingAs($editor)->post($url, ['purpose' => 'Sichtprüfung'])->assertForbidden();
$response = $this->actingAs(followUpAdmin())->post($url, ['purpose' => 'Restnachweis prüfen']);
$response->assertSuccessful();
expect($response->streamedContent())->toBe('original-bytes');
});
// ---------------------------------------------------------------------------
// A-15: Schlüsselrotation und abgeleitete Signaturschlüssel
// ---------------------------------------------------------------------------
test('entries carry a key id and stay readable after a key rotation with the previous key', function () {
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$oldKey = config('archive.key');
$old = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, ['alt' => true]);
expect($old->key_id)->toBe($archive->keyId())->toHaveLength(16);
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
expect(fn () => $archive->manifest($old->fresh(), $admin, 'Test'))->toThrow(RuntimeException::class);
config()->set('archive.previous_keys', [$oldKey]);
$new = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, ['neu' => true]);
expect($archive->manifest($old->fresh(), $admin, 'Test')['alt'])->toBeTrue()
->and($archive->manifest($new->fresh(), $admin, 'Test')['neu'])->toBeTrue()
->and($new->key_id)->not->toBe($old->key_id);
// Einträge vor der Rotation ohne Schlüssel-ID: alle bekannten Schlüssel werden probiert.
DB::table('archive_entries')->where('id', $old->id)->update(['key_id' => null]);
expect($archive->manifest($old->fresh(), $admin, 'Test')['alt'])->toBeTrue();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('ok');
DB::table('archive_entries')->where('id', $old->id)->update(['key_id' => $old->key_id]);
config()->set('archive.previous_keys', []);
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('critical')
->and($checks->get('archive_key')['detail'])->toContain('unbekanntem Schlüssel');
});
test('exports are signed with a derived key and old exports signed with the raw key still verify', function () {
/** @var TestCase $this */
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$entry = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$export = $archive->export($entry, $admin, 'Anwalt');
$temp = tempnam(sys_get_temp_dir(), 'pk-test-');
file_put_contents($temp, $export);
$zip = new ZipArchive;
$zip->open($temp);
$signature = (string) $zip->getFromName('signature.txt');
$zip->close();
$rawKey = base64_decode(substr((string) config('archive.key'), 7));
expect($signature)->toContain('hmac-sha256-hkdf=')
->and($signature)->toContain('key-id='.$archive->keyId())
->and($signature)->not->toContain(hash_hmac('sha256', (string) $entry->payload_hash, $rawKey))
->and($archive->verifyExportSignature((string) $entry->payload_hash, $signature))->toBeTrue()
->and($archive->verifyExportSignature(str_repeat('0', 64), $signature))->toBeFalse();
$legacy = 'sha256=abc'."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, $rawKey)."\n";
expect($archive->verifyExportSignature((string) $entry->payload_hash, $legacy))->toBeTrue();
$this->artisan('archive:verify-export', ['path' => $temp])->expectsOutputToContain('gültig')->assertSuccessful();
unlink($temp);
});
test('a tampered tombstone export is refused by the restore reconcile', function () {
/** @var TestCase $this */
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$due = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$victim = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
DB::table('archive_entries')->where('id', $victim->id)->update(['retain_until' => now()->addYears(10)->toDateString()]);
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
$this->artisan('archive:purge')->assertSuccessful();
$this->artisan('archive:export-tombstones')->assertSuccessful();
$path = (string) config('archive.tombstone_export_path');
$export = json_decode((string) Storage::disk('archive')->get($path), true);
expect($export['hmac'])->toBeString()
->and($export['entries'][0]['id'])->toBe($due->id);
$export['entries'][] = ['id' => $victim->id, 'payload_path' => $victim->payload_path, 'deleted_at' => now()->toIso8601String()];
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('Signatur ungültig')->assertFailed();
expect(Storage::disk('archive')->exists($victim->payload_path))->toBeTrue()
->and($victim->fresh()->isTombstoned())->toBeFalse();
});
/**
* Zwei Einträge, der erste nach Fristende gelöscht und exportiert.
*
* @return array{due: ArchiveEntry, victim: ArchiveEntry, path: string}
*/
function followUpPurgedExport(): array
{
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$due = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$victim = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
DB::table('archive_entries')->where('id', $victim->id)->update(['retain_until' => now()->addYears(10)->toDateString()]);
test()->travelTo(now()->endOfYear()->addYears(3)->addDay());
test()->artisan('archive:purge')->assertSuccessful();
test()->artisan('archive:export-tombstones')->assertSuccessful();
return ['due' => $due, 'victim' => $victim, 'path' => (string) config('archive.tombstone_export_path')];
}
// ---------------------------------------------------------------------------
// Umsetzungsaudit 02.10.2026, O04/O05: Restore-Identität und Export-Vertrauen
// ---------------------------------------------------------------------------
test('the restore reconcile never tombstones a different entry that reuses an exported id', function () {
/** @var TestCase $this */
['due' => $due, 'victim' => $other] = followUpPurgedExport();
// „Älterer DB-Stand": die gelöschte Zeile fehlt, ID 1 trägt jetzt einen
// anderen Eintrag mit eigener Payload.
DB::table('archive_entries')->where('id', $due->id)->delete();
DB::table('archive_entries')->where('id', $other->id)->update(['id' => $due->id]);
$reused = ArchiveEntry::query()->findOrFail($due->id);
$this->artisan('archive:reconcile-after-restore')
->expectsOutputToContain('passen nicht zur Datenbank')
->assertFailed();
expect($reused->fresh()->isTombstoned())->toBeFalse()
->and($reused->payload_path)->not->toBe($due->payload_path)
->and(Storage::disk('archive')->exists($reused->payload_path))->toBeTrue();
});
test('a tombstone export stripped of its signature is not trusted unless explicitly accepted', function (mixed $hmac) {
/** @var TestCase $this */
['victim' => $victim, 'path' => $path] = followUpPurgedExport();
$export = json_decode((string) Storage::disk('archive')->get($path), true);
$export['entries'][] = ['id' => $victim->id, 'payload_path' => $victim->payload_path, 'deleted_at' => now()->toIso8601String()];
if ($hmac === 'missing') {
unset($export['hmac']);
} else {
$export['hmac'] = $hmac;
}
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('ohne Signatur — verworfen')->assertFailed();
expect($victim->fresh()->isTombstoned())->toBeFalse()
->and(Storage::disk('archive')->exists($victim->payload_path))->toBeTrue();
})->with(['missing', null]);
test('reregister refuses an invalid or unsigned export and registers nothing', function () {
/** @var TestCase $this */
['due' => $due, 'path' => $path] = followUpPurgedExport();
// Gelöschter Eintrag: Zeile weg, Payload (älterer Disk-Stand) wieder da.
DB::table('archive_entries')->where('id', $due->id)->delete();
Storage::disk('archive')->put($due->payload_path, 'restored-cipher');
$export = json_decode((string) Storage::disk('archive')->get($path), true);
$export['hmac'] = str_repeat('0', 64);
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reregister')->expectsOutputToContain('ungültiger Signatur')->assertFailed();
unset($export['hmac']);
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reregister')->expectsOutputToContain('ohne Signatur')->assertFailed();
expect(ArchiveEntry::query()->where('payload_path', $due->payload_path)->exists())->toBeFalse();
// Ausdrücklich angenommen (Altformat): der Export gilt, der gelöschte
// Eintrag bleibt draußen.
$this->artisan('archive:reregister', ['--accept-unsigned' => true])->expectsOutputToContain('1 laut Tombstone-Export gelöscht')->assertSuccessful();
expect(ArchiveEntry::query()->where('payload_path', $due->payload_path)->exists())->toBeFalse();
});
test('archive_entries.payload_path is indexed for the orphan sweep', function () {
expect(Schema::hasIndex('archive_entries', 'archive_entries_payload_path_idx'))->toBeTrue();
});
// ---------------------------------------------------------------------------
// 5.1 d, PortalScope
// ---------------------------------------------------------------------------
test('a depublication from the report procedure carries the legal request and ignores the portal scope', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$contact = Contact::factory()->create(['portal' => Portal::Presseecho->value, 'company_id' => $pr->company_id]);
$pr->contacts()->attach($contact->id);
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
$this->actingAs(followUpAdmin());
CurrentPortalContext::set(Portal::Businessportal24);
app(PressReleaseService::class)->depublish($pr, 'Depublizierung im Meldeverfahren '.$legalRequest->reference(), $legalRequest);
CurrentPortalContext::clear();
$entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
$manifest = followUpManifest($entry);
expect($entry->legal_request_id)->toBe($legalRequest->id)
->and($entry->reason_category)->toBe(ArchiveReasonCategory::ReportProcedure)
->and($manifest['legal_request']['id'])->toBe($legalRequest->id)
->and($manifest['press_release']['company']['name'])->toBe('Beispiel AG')
->and($manifest['contacts'])->toHaveCount(1);
});

View file

@ -123,7 +123,7 @@ test('an entry round-trips through encryption with hash check, manifest and sign
file_put_contents($temp, $export);
$outer = new ZipArchive;
$outer->open($temp);
expect($outer->getFromName('signature.txt'))->toContain('hmac-sha256=')
expect($outer->getFromName('signature.txt'))->toContain('hmac-sha256-hkdf=')
->and(json_decode((string) $outer->getFromName('metadata.json'), true)['payload_sha256'])->toBe($entry->payload_hash)
->and($outer->getFromName('payload.zip'))->toBe($zip);
$outer->close();
@ -311,7 +311,8 @@ test('the admin archive page lists entries and downloads only with a purpose', f
expect(ArchiveAccessLog::query()->count())->toBe(0);
$this->actingAs($admin)->get('https://pressekonto.test/admin/archive/'.$entry->id.'/download?purpose=Auskunft')
// Zweck nur per POST (Umsetzungsaudit 29.09.2026, A-08).
$this->actingAs($admin)->post('https://pressekonto.test/admin/archive/'.$entry->id.'/download', ['purpose' => 'Auskunft'])
->assertSuccessful()
->assertHeader('Content-Type', 'application/zip');
@ -422,7 +423,8 @@ test('record leaves neither a row nor a temp file behind when the disk fails, an
expect(ArchiveEntry::query()->count())->toBe(0)
->and(Storage::disk('archive')->allFiles('entries'))->toBe([]);
// Verwaiste Dateien: eine alte (weg), eine frische (bleibt), eine mit Zeile (bleibt).
// Verwaiste Dateien: eine alte (Quarantäne, A-03), eine alte .tmp (weg),
// eine frische (bleibt), eine mit Zeile (bleibt).
$kept = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
Storage::disk('archive')->put('entries/2026/stale.bin.tmp', 'x');
@ -431,10 +433,11 @@ test('record leaves neither a row nor a temp file behind when the disk fails, an
touch(Storage::disk('archive')->path('entries/2026/stale.bin.tmp'), time() - 90000);
touch(Storage::disk('archive')->path($kept->payload_path), time() - 90000);
$this->artisan('archive:sweep-orphans', ['--dry-run' => true])->expectsOutputToContain('2 verwaist, 0 entfernt')->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 2 entfernt')->assertSuccessful();
$this->artisan('archive:sweep-orphans', ['--dry-run' => true])->expectsOutputToContain('2 verwaist, 0 in Quarantäne, 0 entfernt')->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 1 in Quarantäne, 1 entfernt')->assertSuccessful();
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse()
->and(Storage::disk('archive')->exists('quarantine/'.now()->toDateString().'/entries/2026/stale.bin'))->toBeTrue()
->and(Storage::disk('archive')->exists('entries/2026/stale.bin.tmp'))->toBeFalse()
->and(Storage::disk('archive')->exists('entries/2026/fresh.bin'))->toBeTrue()
->and(Storage::disk('archive')->exists($kept->payload_path))->toBeTrue();
@ -487,7 +490,8 @@ test('a failed anonymisation archive still removes the image and is completed by
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(ArchiveEntry::query()->count())->toBe(0);
// Nachträgliche Änderung darf den Vorher-Stand des Nachweises nicht verfälschen.
// Nachträgliche Änderung darf den Nachweis nicht verfälschen: Vorher-
// und Nachher-Stand stammen aus dem Auftrag (Umsetzungsaudit 29.09., A-04).
$pr->forceFill(['text' => '<p>Ganz neuer Text.</p>'])->save();
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 2')->assertSuccessful();
@ -500,7 +504,8 @@ test('a failed anonymisation archive still removes the image and is completed by
->and($removed->legal_request_id)->toBe($legalRequest->id)
->and($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr)
->and($manifest['before']['text'])->toContain('Max Mustermann')
->and($manifest['after']['text'])->toContain('Ganz neuer Text')
->and($manifest['after']['text'])->toContain('[entfernt] war dabei')
->and($manifest['after']['text'])->not->toContain('Ganz neuer Text')
->and($manifest['redaction_snapshot_id'])->toBe(PressReleaseRedactionSnapshot::query()->sole()->id)
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($pr->fresh()->archive_pending_at)->toBeNull();