onQueue('media-storage'); } public function handle(EventStorageManager $storageManager): void { $event = Event::with('storageAssignments.storageTarget')->find($this->eventId); if (! $event) { Log::warning('Archive job aborted: event missing', ['event_id' => $this->eventId]); return; } $archiveDisk = $storageManager->getArchiveDiskForEvent($event); if (! $archiveDisk) { Log::warning('Archive job aborted: no archive disk configured', ['event_id' => $event->id]); return; } $archiveAssignment = $storageManager->ensureAssignment($event, null, 'archive'); $archiveTargetId = $archiveAssignment->media_storage_target_id; $assets = EventMediaAsset::where('event_id', $event->id) ->whereIn('status', ['hot', 'pending', 'restoring']) ->orderBy('id') ->get(); foreach ($assets as $asset) { $sourceDisk = $asset->disk; if ($sourceDisk === $archiveDisk && $asset->status === 'archived') { continue; } $archivePath = $asset->path; $stream = null; try { $stream = Storage::disk($sourceDisk)->readStream($asset->path); if (! $stream) { throw new \RuntimeException('Source stream is null'); } Storage::disk($archiveDisk)->put($archivePath, $stream); $checksumMeta = null; $archiveChecksum = null; if ($this->checksumValidationEnabled()) { $archiveChecksum = $this->computeChecksum($archiveDisk, $archivePath); if (! $archiveChecksum) { throw new \RuntimeException('Archive checksum unavailable'); } $expectedChecksum = $asset->checksum; if ($expectedChecksum) { if (! hash_equals($expectedChecksum, $archiveChecksum)) { $this->handleChecksumMismatch($asset, $expectedChecksum, $archiveChecksum, $sourceDisk, $archiveDisk); $this->deleteArchiveCopy($archiveDisk, $archivePath); continue; } $checksumMeta = [ 'checksum_status' => 'verified', 'checksum_verified_at' => now()->toIso8601String(), ]; } else { $asset->checksum = $archiveChecksum; $checksumMeta = [ 'checksum_status' => 'seeded', 'checksum_verified_at' => now()->toIso8601String(), ]; } } $asset->fill([ 'disk' => $archiveDisk, 'media_storage_target_id' => $archiveTargetId, 'status' => 'archived', 'archived_at' => now(), 'error_message' => null, 'checksum' => $asset->checksum, 'meta' => $this->mergeMeta($asset->meta, $checksumMeta), ])->save(); if ($this->deleteSource) { Storage::disk($sourceDisk)->delete($asset->path); } } catch (\Throwable $e) { Log::error('Failed to archive media asset', [ 'asset_id' => $asset->id, 'event_id' => $event->id, 'source_disk' => $sourceDisk, 'archive_disk' => $archiveDisk, 'error' => $e->getMessage(), ]); $asset->update([ 'status' => 'failed', 'error_message' => $e->getMessage(), ]); } finally { if (is_resource($stream)) { fclose($stream); } } } } private function checksumValidationEnabled(): bool { return (bool) config('storage-monitor.checksum_validation.enabled', true); } private function computeChecksum(string $disk, string $path): ?string { try { $stream = Storage::disk($disk)->readStream($path); } catch (\Throwable $e) { Log::channel('storage-jobs')->warning('Failed to open stream for checksum', [ 'disk' => $disk, 'path' => $path, 'error' => $e->getMessage(), ]); return null; } if (! $stream) { return null; } try { $context = hash_init('sha256'); $ok = hash_update_stream($context, $stream); if ($ok === false) { return null; } return hash_final($context); } finally { if (is_resource($stream)) { fclose($stream); } } } private function handleChecksumMismatch( EventMediaAsset $asset, string $expectedChecksum, string $actualChecksum, string $sourceDisk, string $archiveDisk, ): void { Log::channel('storage-jobs')->alert('Checksum mismatch detected during archive', [ 'asset_id' => $asset->id, 'event_id' => $asset->event_id, 'source_disk' => $sourceDisk, 'archive_disk' => $archiveDisk, 'expected_checksum' => $expectedChecksum, 'actual_checksum' => $actualChecksum, ]); $asset->update([ 'status' => 'failed', 'error_message' => 'checksum_mismatch', 'meta' => $this->mergeMeta($asset->meta, [ 'checksum_status' => 'mismatch', 'checksum_verified_at' => now()->toIso8601String(), 'checksum_expected' => $expectedChecksum, 'checksum_actual' => $actualChecksum, ]), ]); } private function deleteArchiveCopy(string $archiveDisk, string $path): void { try { Storage::disk($archiveDisk)->delete($path); } catch (\Throwable $e) { Log::channel('storage-jobs')->warning('Failed to clean up archive copy after checksum mismatch', [ 'disk' => $archiveDisk, 'path' => $path, 'error' => $e->getMessage(), ]); } } private function mergeMeta(?array $meta, ?array $updates): ?array { if (! $updates) { return $meta; } return array_merge($meta ?? [], $updates); } }