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:
parent
c82f279fef
commit
60650baeb8
29 changed files with 2918 additions and 412 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
app/Console/Commands/ArchiveReregister.php
Normal file
62
app/Console/Commands/ArchiveReregister.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
app/Console/Commands/ArchiveVerifyExport.php
Normal file
54
app/Console/Commands/ArchiveVerifyExport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class ArchiveEntry extends Model
|
|||
'payload_path',
|
||||
'payload_hash',
|
||||
'payload_bytes',
|
||||
'key_id',
|
||||
'retain_until',
|
||||
'legal_hold',
|
||||
'legal_hold_note',
|
||||
|
|
|
|||
74
app/Models/ArchivePendingJob.php
Normal file
74
app/Models/ArchivePendingJob.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
266
app/Services/Archive/ArchivePendingQueue.php
Normal file
266
app/Services/Archive/ArchivePendingQueue.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue