- 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>
1085 lines
40 KiB
PHP
1085 lines
40 KiB
PHP
<?php
|
|
|
|
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;
|
|
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;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
use ZipArchive;
|
|
|
|
/**
|
|
* Beweisarchiv (Bildkennzeichnung Block F, Decision-Update Abschnitt 4).
|
|
*
|
|
* Jeder Eintrag ist ein verschlüsseltes ZIP (manifest.json plus Dateien)
|
|
* auf der Archiv-Disk, eigener Schlüssel (ARCHIVE_KEY), SHA-256 des
|
|
* Klartext-ZIPs am Eintrag. Lesen nur über open()/export() mit Zweck und
|
|
* Zugriffsprotokoll. Löschen nur durch purgeDue() nach Frist ohne
|
|
* legal_hold; die Zeile bleibt als Tombstone. Nach einem Restore aus einer
|
|
* Sicherung entfernt reconcileAfterRestore() wiederhergestellte Payloads
|
|
* anhand des täglich exportierten Tombstone-Stands und der Tombstones in
|
|
* der Datenbank.
|
|
*
|
|
* Dateioperationen werden ausgewertet (Umsetzungsaudit 25.09.2026, N10):
|
|
* 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
|
|
* noch .tmp zurück (N11). Eine Datei ohne Zeile (Commit nach dem
|
|
* Verschieben gescheitert) räumt sweepOrphans() auf.
|
|
*
|
|
* @param array<string, mixed> $manifest Fachdaten (werden als manifest.json abgelegt)
|
|
* @param array<string, string> $files Dateiname → Bytes
|
|
* @param array<string, mixed> $summary Kleine, nicht personenbezogene Kennzahlen für die Liste
|
|
*/
|
|
public function record(
|
|
ArchiveEvent $event,
|
|
Model $subject,
|
|
?int $pressReleaseId,
|
|
ArchiveReasonCategory $reason,
|
|
string $actorType,
|
|
?int $actorId,
|
|
array $manifest,
|
|
array $files = [],
|
|
array $summary = [],
|
|
?int $legalRequestId = null,
|
|
): ?ArchiveEntry {
|
|
if (! $this->isConfigured()) {
|
|
Log::error('Beweisarchiv: ARCHIVE_KEY fehlt, Eintrag konnte nicht abgelegt werden.', [
|
|
'event' => $event->value,
|
|
'subject' => $subject->getMorphClass().'#'.$subject->getKey(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
|
|
$manifest = [
|
|
'event' => $event->value,
|
|
'subject_type' => $subject->getMorphClass(),
|
|
'subject_id' => $subject->getKey(),
|
|
'press_release_id' => $pressReleaseId,
|
|
'legal_request_id' => $legalRequestId,
|
|
'reason_category' => $reason->value,
|
|
'actor_type' => $actorType,
|
|
'actor_id' => $actorId,
|
|
'recorded_at' => now()->toIso8601String(),
|
|
] + $manifest;
|
|
|
|
$zip = $this->buildZip($manifest, $files);
|
|
$hash = hash('sha256', $zip);
|
|
$path = sprintf('entries/%s/%s.bin', now()->format('Y'), Str::uuid()->toString());
|
|
$temp = $path.'.tmp';
|
|
|
|
if (! $this->disk()->put($temp, $this->encrypter()->encrypt($zip, false))) {
|
|
throw new RuntimeException('Beweisarchiv: Payload konnte nicht geschrieben werden.');
|
|
}
|
|
|
|
try {
|
|
return DB::transaction(function () use ($subject, $pressReleaseId, $legalRequestId, $event, $actorType, $actorId, $reason, $summary, $path, $temp, $hash, $zip): ArchiveEntry {
|
|
$entry = ArchiveEntry::query()->create([
|
|
'subject_type' => $subject->getMorphClass(),
|
|
'subject_id' => $subject->getKey(),
|
|
'press_release_id' => $pressReleaseId,
|
|
'legal_request_id' => $legalRequestId,
|
|
'event' => $event,
|
|
'actor_type' => $actorType,
|
|
'actor_id' => $actorId,
|
|
'reason_category' => $reason,
|
|
'summary' => $summary ?: null,
|
|
'payload_path' => $path,
|
|
'payload_hash' => $hash,
|
|
'payload_bytes' => strlen($zip),
|
|
'key_id' => $this->keyId(),
|
|
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
if (! $this->disk()->move($temp, $path) || ! $this->disk()->exists($path)) {
|
|
throw new RuntimeException('Beweisarchiv: Payload konnte nicht abgelegt werden.');
|
|
}
|
|
|
|
return $entry;
|
|
});
|
|
} catch (Throwable $exception) {
|
|
$this->disk()->delete($temp);
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Entschlüsseltes ZIP eines Eintrags — protokolliert mit Zweck.
|
|
*/
|
|
public function open(ArchiveEntry $entry, User $admin, string $purpose): string
|
|
{
|
|
$this->logAccess($entry, $admin, 'open', $purpose);
|
|
|
|
return $this->plaintext($entry);
|
|
}
|
|
|
|
/**
|
|
* manifest.json eines Eintrags als Array — protokolliert mit Zweck.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function manifest(ArchiveEntry $entry, User $admin, string $purpose): array
|
|
{
|
|
$this->logAccess($entry, $admin, 'manifest', $purpose);
|
|
|
|
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. 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
|
|
{
|
|
$this->logAccess($entry, $admin, 'export', $purpose);
|
|
|
|
$payload = $this->plaintext($entry);
|
|
|
|
return $this->buildZip([
|
|
'entry_id' => $entry->getKey(),
|
|
'event' => $entry->event->value,
|
|
'subject' => $entry->subject_type.'#'.$entry->subject_id,
|
|
'press_release_id' => $entry->press_release_id,
|
|
'legal_request_id' => $entry->legal_request_id,
|
|
'reason_category' => $entry->reason_category->value,
|
|
'created_at' => $entry->created_at?->toIso8601String(),
|
|
'retain_until' => $entry->retain_until->toDateString(),
|
|
'legal_hold' => $entry->legal_hold,
|
|
'payload_sha256' => $entry->payload_hash,
|
|
'exported_at' => now()->toIso8601String(),
|
|
'exported_by' => $admin->getKey(),
|
|
], [
|
|
'payload.zip' => $payload,
|
|
'signature.txt' => 'sha256='.hash('sha256', $payload)
|
|
."\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
|
|
* Purge fertig ist — dann ist der Eintrag Tombstone und der Hold wird
|
|
* abgelehnt (N02).
|
|
*/
|
|
public function setLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
|
{
|
|
DB::transaction(function () use ($entry, $admin, $note): void {
|
|
$locked = $this->lockedEntry($entry);
|
|
|
|
if ($locked->isTombstoned()) {
|
|
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone), ein Legal Hold ist nicht mehr möglich.');
|
|
}
|
|
|
|
$locked->forceFill([
|
|
'legal_hold' => true,
|
|
'legal_hold_note' => Str::limit($note, 990, ''),
|
|
'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();
|
|
}
|
|
|
|
/**
|
|
* Aufhebung: die Frist läuft ab dem Aufhebungsdatum neu (4.3).
|
|
*/
|
|
public function releaseLegalHold(ArchiveEntry $entry, User $admin, string $note): void
|
|
{
|
|
DB::transaction(function () use ($entry, $admin, $note): void {
|
|
$locked = $this->lockedEntry($entry);
|
|
|
|
if ($locked->isTombstoned()) {
|
|
throw new RuntimeException('Beweisarchiv: Eintrag ist bereits nach Fristablauf gelöscht (Tombstone).');
|
|
}
|
|
|
|
$locked->forceFill([
|
|
'legal_hold' => false,
|
|
'legal_hold_note' => Str::limit($note, 990, ''),
|
|
'legal_hold_set_by' => $admin->getKey(),
|
|
'legal_hold_set_at' => now(),
|
|
'retain_until' => ArchiveEntry::retentionEndFor(now()),
|
|
])->save();
|
|
|
|
$this->logAccess($locked, $admin, 'hold_released', $note);
|
|
});
|
|
|
|
$entry->refresh();
|
|
}
|
|
|
|
/**
|
|
* Fällige Einträge: Payload löschen, Tombstone setzen, Log ohne Inhalt.
|
|
* Jeder Eintrag wird unter Zeilensperre erneut geprüft; Fehlschläge
|
|
* werden gezählt und beim nächsten Lauf wiederholt.
|
|
*
|
|
* @return array{due: int, purged: int, failed: int}
|
|
*/
|
|
public function purgeDue(bool $dryRun = false): array
|
|
{
|
|
$result = ['due' => 0, 'purged' => 0, 'failed' => 0];
|
|
|
|
ArchiveEntry::query()->dueForPurge()->orderBy('id')->chunkById(200, function ($entries) use (&$result, $dryRun): void {
|
|
foreach ($entries as $entry) {
|
|
$result['due']++;
|
|
|
|
if ($dryRun) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if ($this->tombstone($entry)) {
|
|
$result['purged']++;
|
|
}
|
|
} catch (Throwable $exception) {
|
|
$result['failed']++;
|
|
Log::error('Beweisarchiv: Löschung nach Fristablauf fehlgeschlagen, wird wiederholt.', [
|
|
'entry_id' => $entry->getKey(),
|
|
'error' => $exception->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
});
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Tombstone-Stand exportieren, damit er einen Restore überlebt. Wird
|
|
* 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
|
|
{
|
|
$path = (string) config('archive.tombstone_export_path');
|
|
$temp = $path.'.tmp';
|
|
$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 ($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)) {
|
|
$this->disk()->delete($temp);
|
|
|
|
throw new RuntimeException('Beweisarchiv: Tombstone-Export konnte nicht abgelegt werden.');
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Nach dem Einspielen einer Sicherung: alles, was im exportierten
|
|
* 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, conflicts: int, export_signature: string, sweep_paused: bool}
|
|
*/
|
|
public function reconcileAfterRestore(bool $acceptUnsigned = false): array
|
|
{
|
|
$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) {
|
|
$path = is_string($row['payload_path'] ?? null) ? $row['payload_path'] : null;
|
|
|
|
if ($path !== null && ! isset($handled[$path]) && $this->disk()->exists($path)) {
|
|
$handled[$path] = true;
|
|
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
|
}
|
|
|
|
// 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) {
|
|
$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')
|
|
->chunkById(500, function ($entries) use (&$result, &$handled): void {
|
|
foreach ($entries as $entry) {
|
|
$path = (string) $entry->payload_path;
|
|
|
|
if (isset($handled[$path]) || ! $this->disk()->exists($path)) {
|
|
continue;
|
|
}
|
|
|
|
$handled[$path] = true;
|
|
FileDeletion::delete($this->disk(), $path) ? $result['files_removed']++ : $result['failed']++;
|
|
}
|
|
});
|
|
|
|
if ($result['failed'] > 0) {
|
|
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)
|
|
* 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{paused: bool, scanned: int, orphans: int, quarantined: int, removed: int, expired: int}
|
|
*/
|
|
public function sweepOrphans(bool $dryRun = false, int $olderThanHours = 24): array
|
|
{
|
|
$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 ($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) {
|
|
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;
|
|
}
|
|
|
|
if (isset($tombstoned[$target])) {
|
|
$result['skipped_tombstoned']++;
|
|
|
|
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()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
* fällig ist (Hold gesetzt, Frist verlängert, schon Tombstone).
|
|
*/
|
|
private function tombstone(ArchiveEntry $entry): bool
|
|
{
|
|
$done = DB::transaction(function () use ($entry): bool {
|
|
$locked = $this->lockedEntry($entry);
|
|
|
|
if ($locked->isTombstoned() || $locked->legal_hold || $locked->retain_until->gte(today())) {
|
|
return false;
|
|
}
|
|
|
|
if ($locked->payload_path !== null) {
|
|
FileDeletion::deleteOrThrow($this->disk(), $locked->payload_path, 'Beweisarchiv');
|
|
}
|
|
|
|
$this->purgeApplicationCopies($locked);
|
|
|
|
// Pfad bleibt eingetragen, damit der Restore-Abgleich die Datei
|
|
// wiederfindet; der Inhalt ist weg.
|
|
$locked->forceFill(['deleted_at' => now()])->save();
|
|
|
|
return true;
|
|
});
|
|
|
|
if ($done) {
|
|
Log::info('Beweisarchiv: Eintrag nach Fristablauf gelöscht.', ['entry_id' => $entry->getKey(), 'event' => $entry->event->value]);
|
|
}
|
|
|
|
return $done;
|
|
}
|
|
|
|
/**
|
|
* Mit dem Archiv-Tombstone eines entfernten Bildes verschwinden auch die
|
|
* Kopien in der Anwendung: Bestätigungen, Korrekturen und die weich
|
|
* gelöschte Bildzeile (5.1 a). Das Archiv hielt die vollständige Kopie.
|
|
*/
|
|
private function purgeApplicationCopies(ArchiveEntry $entry): void
|
|
{
|
|
if (! in_array($entry->event, [ArchiveEvent::ImageRemoved, ArchiveEvent::ImageReplaced], true)
|
|
|| $entry->subject_type !== (new PressReleaseImage)->getMorphClass()) {
|
|
return;
|
|
}
|
|
|
|
$image = PressReleaseImage::withTrashed()->find($entry->subject_id);
|
|
|
|
if ($image !== null && ! $image->trashed()) {
|
|
return;
|
|
}
|
|
|
|
ImageRightsConfirmation::query()
|
|
->where('subject_type', $entry->subject_type)
|
|
->where('subject_id', $entry->subject_id)
|
|
->get()
|
|
->each(fn (ImageRightsConfirmation $confirmation) => $confirmation->purgeAfterArchive());
|
|
|
|
ImageDeclarationCorrection::query()
|
|
->where('press_release_image_id', $entry->subject_id)
|
|
->get()
|
|
->each(fn (ImageDeclarationCorrection $correction) => $correction->purgeAfterArchive());
|
|
|
|
$image?->forceDelete();
|
|
}
|
|
|
|
private function lockedEntry(ArchiveEntry $entry): ArchiveEntry
|
|
{
|
|
return ArchiveEntry::query()->whereKey($entry->getKey())->lockForUpdate()->firstOrFail();
|
|
}
|
|
|
|
private function logAccess(ArchiveEntry $entry, User $admin, string $action, string $purpose): void
|
|
{
|
|
$purpose = trim($purpose);
|
|
|
|
if ($purpose === '') {
|
|
throw new RuntimeException('Beweisarchiv: Zugriff ohne Zweck ist nicht vorgesehen.');
|
|
}
|
|
|
|
ArchiveAccessLog::query()->create([
|
|
'entry_id' => $entry->getKey(),
|
|
'admin_id' => $admin->getKey(),
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
private function plaintext(ArchiveEntry $entry): string
|
|
{
|
|
if ($entry->isTombstoned() || $entry->payload_path === null) {
|
|
throw new RuntimeException('Beweisarchiv: Eintrag ist nach Fristablauf gelöscht (Tombstone).');
|
|
}
|
|
|
|
$cipher = $this->disk()->get($entry->payload_path);
|
|
|
|
if (! is_string($cipher) || $cipher === '') {
|
|
throw new RuntimeException('Beweisarchiv: Payload fehlt auf der Archiv-Disk.');
|
|
}
|
|
|
|
$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.');
|
|
}
|
|
|
|
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
|
|
*/
|
|
private function buildZip(array $manifest, array $files, string $manifestName = 'manifest.json'): string
|
|
{
|
|
$temp = $this->tempFile('');
|
|
$zip = new ZipArchive;
|
|
|
|
try {
|
|
if ($zip->open($temp, ZipArchive::OVERWRITE) !== true) {
|
|
throw new RuntimeException('Beweisarchiv: ZIP konnte nicht erzeugt werden.');
|
|
}
|
|
|
|
$zip->addFromString($manifestName, (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
|
|
foreach ($files as $name => $bytes) {
|
|
$zip->addFromString($name, $bytes);
|
|
}
|
|
|
|
$zip->close();
|
|
|
|
return (string) file_get_contents($temp);
|
|
} finally {
|
|
@unlink($temp);
|
|
}
|
|
}
|
|
|
|
private function tempFile(string $contents): string
|
|
{
|
|
$temp = tempnam(sys_get_temp_dir(), 'pk-archive-');
|
|
|
|
if ($temp === false) {
|
|
throw new RuntimeException('Beweisarchiv: keine temporäre Datei verfügbar.');
|
|
}
|
|
|
|
file_put_contents($temp, $contents);
|
|
|
|
return $temp;
|
|
}
|
|
|
|
private function encrypter(): Encrypter
|
|
{
|
|
$key = $this->keyBytes();
|
|
|
|
if ($key === null) {
|
|
throw new RuntimeException('Beweisarchiv: ARCHIVE_KEY fehlt.');
|
|
}
|
|
|
|
return new Encrypter($key, (string) config('archive.cipher', 'AES-256-CBC'));
|
|
}
|
|
|
|
private function keyBytes(): ?string
|
|
{
|
|
return $this->decodeKey((string) config('archive.key'));
|
|
}
|
|
|
|
private function decodeKey(string $key): ?string
|
|
{
|
|
if ($key === '') {
|
|
return null;
|
|
}
|
|
|
|
if (Str::startsWith($key, 'base64:')) {
|
|
$key = base64_decode(substr($key, 7), true) ?: '';
|
|
}
|
|
|
|
return strlen($key) === 32 ? $key : null;
|
|
}
|
|
|
|
private function disk(): Filesystem
|
|
{
|
|
return Storage::disk((string) config('archive.disk', 'archive'));
|
|
}
|
|
}
|